Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6673c6420 | ||
|
|
6e524ec616 | ||
|
|
6c5a45e195 | ||
|
|
c2fdc05e5c | ||
|
|
fa43c2f4e9 | ||
|
|
a0c789b3ba | ||
|
|
22a9a279b1 | ||
|
|
0ab7d94294 | ||
|
|
6e891357ff | ||
|
|
85ead4d66b | ||
|
|
c5044339a1 | ||
|
|
c268ae4f23 | ||
|
|
b7e0e5dbba | ||
|
|
e14d9d340a | ||
|
|
fa89da1fab | ||
|
|
13a88179b8 | ||
|
|
b91091caca | ||
|
|
f50204a98b | ||
|
|
1a49ae7ea9 | ||
|
|
e7af7a4b77 | ||
|
|
396e91e609 | ||
|
|
3f0eef145b | ||
|
|
4f351c10ca | ||
|
|
d9e5753dc2 | ||
|
|
8c22425e91 | ||
|
|
9810a75564 | ||
|
|
606e345580 | ||
|
|
ad48d30c68 | ||
|
|
23fd2da91e | ||
|
|
3d490bb6f3 | ||
|
|
86f1e4a08f | ||
|
|
c255b170d4 | ||
|
|
1d3cc7bcd4 | ||
|
|
ae2053d2ed | ||
|
|
47f108c9c8 | ||
|
|
fe18aaa956 | ||
|
|
20e9d535de | ||
|
|
988e1d3f00 | ||
|
|
6fbee27f9c | ||
|
|
cddaf35280 | ||
|
|
6f173b166b | ||
|
|
f92a3d0a99 | ||
|
|
1e2b42af25 | ||
|
|
a48b034a94 | ||
|
|
ee47a61270 | ||
|
|
68f851110f | ||
|
|
96a6f6e691 | ||
|
|
44b3bcb2b2 | ||
|
|
a45a44ef11 | ||
|
|
56264a9220 | ||
|
|
a88f7c2dd0 | ||
|
|
32ec29fc4a | ||
|
|
ae17b8a8e7 | ||
|
|
eeca4d48c2 | ||
|
|
b2435d97b6 | ||
|
|
9a3c4ec377 | ||
|
|
315c5f19e6 | ||
|
|
1a66d9c3a8 | ||
|
|
77b1a87712 | ||
|
|
68b2a5dc8d | ||
|
|
3cab054684 | ||
|
|
fe1f72ae1b | ||
|
|
761c3b5e82 | ||
|
|
32dafca148 | ||
|
|
668f7faf03 | ||
|
|
d0e3e48943 | ||
|
|
1045db318b | ||
|
|
65af37d159 | ||
|
|
8257e1035c | ||
|
|
bca9e16bd0 | ||
|
|
9ea2a2f9b6 | ||
|
|
ce6a1093a3 | ||
|
|
2707054563 | ||
|
|
24685556b7 | ||
|
|
50e2d308ea | ||
|
|
77c5422951 |
@@ -19,15 +19,9 @@ name: Android
|
||||
|
||||
on:
|
||||
push:
|
||||
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
|
||||
# See desktop.yml for why, and 85ead4d for what the duplication cost.
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- "android/**"
|
||||
# The Rust the .so is built from. A core change reaches the phone exactly
|
||||
# as it reaches the desktop, so this lane has to rebuild on it.
|
||||
- "core/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".forgejo/workflows/android.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -42,8 +36,46 @@ env:
|
||||
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
||||
|
||||
jobs:
|
||||
# Does the APK need rebuilding, or is the channel already serving this source?
|
||||
# See the equivalent job in desktop.yml — same reasoning, same replacement of a
|
||||
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`.
|
||||
#
|
||||
# The guard runs here so it covers the skip path too (§6.3).
|
||||
#
|
||||
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its last step dispatches
|
||||
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
|
||||
# happens — and that is correct, because ci.yml's `gate` stands down only when the
|
||||
# push touched Android's files, which is the same condition that makes this build.
|
||||
# The two decisions agree because they read the same fact; they are still two
|
||||
# readers of it, which is why the gate's grep carries a comment pointing here.
|
||||
decide:
|
||||
name: Build, or is the channel already serving this?
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
outputs:
|
||||
build: ${{ steps.d.outputs.build }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Decide
|
||||
id: d
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
case "$GITHUB_REF_NAME" in
|
||||
main) channel=stable ;;
|
||||
*) channel=dev ;;
|
||||
esac
|
||||
sh packaging/guard-forward.sh android "$channel"
|
||||
echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
name: Kotlin + Rust (APK)
|
||||
needs: [decide]
|
||||
if: needs.decide.outputs.build == 'true'
|
||||
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
|
||||
# proven-working label that can pull our container images.
|
||||
runs-on: flutter-ci
|
||||
@@ -63,6 +95,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Derives a version, so it needs the whole history — see the note in
|
||||
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache Gradle and Cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -85,13 +121,17 @@ jobs:
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
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
|
||||
# versionCode must RISE for Android to accept an update, and the run
|
||||
# number is the same monotonic counter the desktop's version scheme
|
||||
# already uses — no state carried between runs, and immune to the
|
||||
# shallow checkout that makes a commit count useless here.
|
||||
echo "code=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT
|
||||
echo "code=$code" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
|
||||
@@ -105,7 +145,7 @@ jobs:
|
||||
echo "profile=debug" >> $GITHUB_OUTPUT
|
||||
echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT
|
||||
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
|
||||
echo "Signed release build — $version (versionCode $GITHUB_RUN_NUMBER)"
|
||||
echo "Signed release build — $version (versionCode $code)"
|
||||
else
|
||||
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
|
||||
echo "variant=Debug" >> $GITHUB_OUTPUT
|
||||
@@ -199,19 +239,29 @@ jobs:
|
||||
JSON
|
||||
cat dist/thoughtsync-android.json
|
||||
|
||||
# The rolling dev channel, same fixed-tag release the desktop bundles use.
|
||||
# CI artifacts are per-run and auth-gated, so they are no use as a fetch
|
||||
# target; a release asset has a permanent URL. Only ever a SIGNED build —
|
||||
# publishing an unsigned APK would offer people something they cannot
|
||||
# install over what they already have.
|
||||
- name: Publish to the dev channel
|
||||
if: github.ref == 'refs/heads/dev' && steps.build.outputs.keystore != ''
|
||||
# The rolling channel for this branch, the same fixed-tag releases the desktop
|
||||
# bundles use. CI artifacts are per-run and auth-gated, so they are no use as a
|
||||
# fetch target; a release asset has a permanent URL. Only ever a SIGNED build —
|
||||
# publishing an unsigned APK would offer people something they cannot install
|
||||
# over what they already have.
|
||||
#
|
||||
# `stable` from main is new in M314 step 3, and it is what lets the server image
|
||||
# bake in a client that matches its own channel: a :latest image fetches the APK
|
||||
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
|
||||
# all and every image — stable included — baked in the dev one.
|
||||
- name: Publish to the channel for this branch
|
||||
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
|
||||
working-directory: .
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: dev
|
||||
RELEASE_PRERELEASE: "true"
|
||||
run: bash desktop/packaging/publish-release.sh
|
||||
run: |
|
||||
case "$GITHUB_REF_NAME" in
|
||||
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
|
||||
esac
|
||||
export RELEASE_TAG RELEASE_PRERELEASE
|
||||
echo "Publishing the APK to the $RELEASE_TAG channel."
|
||||
bash desktop/packaging/publish-release.sh
|
||||
|
||||
- name: Upload the APK
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
|
||||
+82
-53
@@ -1,12 +1,21 @@
|
||||
# CI runs first; build only proceeds if lint + typecheck pass.
|
||||
#
|
||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
||||
# Push to dev: typecheck + lint + test + build :dev
|
||||
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||
#
|
||||
# main is the production line, so a merge to main rebuilds and moves :latest to its
|
||||
# tip (family rule 46) — no version release required. The :<sha> image is the
|
||||
# immutable rollback unit for every build.
|
||||
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
|
||||
# nothing pins one — verified by looking for a consumer, not for whether one is
|
||||
# imaginable — and the git release tag is a different object in a different system
|
||||
# (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date.
|
||||
#
|
||||
# A `v*` tag builds nothing at all. The merge to main already published everything,
|
||||
# so a tag rebuilding that same source would re-push :<sha> with different bytes,
|
||||
# which rule 145 forbids even when they match.
|
||||
#
|
||||
# main is the production line, so a merge moves :latest to its tip (family rule 46)
|
||||
# — no version release required. :<sha> is the immutable rollback unit, and it is
|
||||
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled,
|
||||
# accumulating forever, for a channel whose entire contract is that it moves.
|
||||
#
|
||||
# Required secret (repo -> Settings -> Secrets -> Actions):
|
||||
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
|
||||
@@ -16,27 +25,29 @@ name: CI & Build
|
||||
|
||||
on:
|
||||
push:
|
||||
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
|
||||
# the image ALWAYS builds. Two reasons:
|
||||
#
|
||||
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any
|
||||
# production commit is addressable. A path filter quietly broke that promise
|
||||
# for a docs-only merge: no trigger, no image, no sha tag for that commit.
|
||||
# * It is the artifact most exposed to base-image staleness (`python:3.12-slim`
|
||||
# is a floating tag and this can face the internet), and building every push
|
||||
# picks those updates up. That is why note 3127 §4's base tension does not
|
||||
# bite here — the one artifact it would apply to never skips.
|
||||
#
|
||||
# Affordable because it is the cheap one: ~15 seconds, against 6 and 9 minutes
|
||||
# for the clients, which is why THEY skip and this does not.
|
||||
branches: [dev, main]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "frontend/**"
|
||||
- "tests/**"
|
||||
- "pyproject.toml"
|
||||
- "alembic/**"
|
||||
- "alembic.ini"
|
||||
- "Dockerfile"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
# Dispatched by the Android lane once it has published a client, so the image
|
||||
# that bakes it in is built AFTER the APK exists rather than racing it. See the
|
||||
# `gate` job below for the other half.
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel older runs on the same branch when a newer push lands. Tag runs get their
|
||||
# own group implicitly and are never cancelled.
|
||||
# Cancel older runs on the same branch when a newer push lands.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -64,7 +75,7 @@ jobs:
|
||||
# than a config so at least it is inspectable in the log.
|
||||
gate:
|
||||
name: Build now, or wait for Android?
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -89,17 +100,6 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# A tag. The Android lane does not run on tags, so nothing would ever
|
||||
# call back — standing down here would mean a release tag that never
|
||||
# produces an image at all.
|
||||
case "${{ github.ref }}" in
|
||||
refs/tags/*)
|
||||
echo "Tag build — the Android lane does not run on tags. Building."
|
||||
echo "build=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# No parent (first commit, or a force-push that orphaned it) — nothing to
|
||||
# compare, so build rather than stall.
|
||||
if ! git rev-parse --verify -q HEAD^ >/dev/null; then
|
||||
@@ -125,7 +125,12 @@ jobs:
|
||||
echo "Changed in this push:"
|
||||
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 "This push also changes the Android client. Standing down: the"
|
||||
echo "Android lane will publish a new APK and dispatch this workflow,"
|
||||
@@ -140,7 +145,7 @@ jobs:
|
||||
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -157,7 +162,7 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Python lint
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -170,7 +175,7 @@ jobs:
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -193,15 +198,14 @@ jobs:
|
||||
# them ever executed by CI — and the schema the migrations build had never been
|
||||
# checked against the models that read it.
|
||||
#
|
||||
# Runs for visibility and does NOT gate the build, matching the `test` lane and
|
||||
# FabledScribe's equivalent job.
|
||||
# Gates the build, along with every other lane — see the `build` job's `needs`.
|
||||
#
|
||||
# Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner
|
||||
# derives the service-container name from the truncated job display name, and the
|
||||
# discovery step below filters `docker ps` by it. Service hostnames are not routable
|
||||
# on this runner (rule 79), so the step resolves the container's bridge IP.
|
||||
integration:
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -261,10 +265,16 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build & push image
|
||||
# Build gates on lint + typecheck. The `test` job runs in parallel for
|
||||
# visibility but does not block dev image builds (DB-backed integration
|
||||
# testing happens against the dev image manually, not on every push).
|
||||
needs: [gate, typecheck, lint]
|
||||
# Every lane gates the build. This once stopped at lint + typecheck, on the
|
||||
# reasoning that DB-backed testing happened manually against the dev image
|
||||
# rather than on every push — true until 6f21db8 added the integration lane,
|
||||
# and false since.
|
||||
#
|
||||
# What that gap cost: run 4293 failed `test` and published :dev and :<sha>
|
||||
# anyway, so the deployed server ran a build whose test lane was red. An image
|
||||
# tag is the rollback substrate (family rule 46); one that can be published
|
||||
# from a failing run is not a substrate you can roll back TO.
|
||||
needs: [gate, typecheck, lint, test, integration]
|
||||
if: needs.gate.outputs.build == 'true'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
@@ -274,27 +284,35 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
|
||||
# and produces a too-low value silently, with the lane green (§6.1).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate image tags and version
|
||||
id: tags
|
||||
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
|
||||
# NOT bash `[[ ]]`.
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
BUILD_VERSION="dev"
|
||||
# The image's version is DERIVED from its own shipped files — including the
|
||||
# Android client it bakes in, which is why an APK-only change re-versions
|
||||
# it. One value and no ordering key: nothing compares a server image, so
|
||||
# §2 says do not invent one just because the other artifacts have one.
|
||||
#
|
||||
# This was a short sha on main and the literal "dev" elsewhere, which could
|
||||
# not answer "how old is this instance?" — the question that actually gets
|
||||
# asked of a self-hosted app running in several places.
|
||||
BUILD_VERSION="$(sh packaging/version.sh display server)"
|
||||
case "${{ github.ref }}" in
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
TAGS="${{ env.IMAGE }}:dev"
|
||||
;;
|
||||
refs/heads/main)
|
||||
# Production line: :latest tracks main's tip (rule 46). No :main tag;
|
||||
# the :<sha> above is the rollback unit. Version label = short sha.
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||
BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)"
|
||||
TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}"
|
||||
;;
|
||||
refs/tags/*)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
*)
|
||||
echo "::error::This lane builds images for dev and main only."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
@@ -325,7 +343,18 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
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
|
||||
for f in thoughtsync.apk thoughtsync-android.json; do
|
||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
|
||||
|
||||
+150
-85
@@ -16,33 +16,20 @@ name: Desktop (Tauri)
|
||||
|
||||
on:
|
||||
push:
|
||||
# NO `paths:` FILTER. It was a second, independent statement of this artifact's
|
||||
# file set, hand-kept beside the one in `packaging/version.sh`, and it drifted
|
||||
# from it within a day (85ead4d). The `decide` job below reads the real set and
|
||||
# skips in seconds when nothing moved — one definition, one reader (§3).
|
||||
#
|
||||
# The cost is that this workflow starts on every push rather than on a matching
|
||||
# one. That is a ~15s container for a decision, against a lane that cannot
|
||||
# silently fail to run.
|
||||
branches: [dev, main]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "desktop/**"
|
||||
# The shared client core (store + sync engine) the desktop wraps. Its own
|
||||
# crate since the Android client binds the same code, so a change there is a
|
||||
# change to this app even though nothing under desktop/ moved.
|
||||
- "core/**"
|
||||
# The Android uniffi shim. It builds no desktop artifact, but it is a
|
||||
# workspace member, so this lane's `cargo clippy --all-targets` is what
|
||||
# compiles and lints it — and until the Android lane exists (M12 step 5),
|
||||
# it is the ONLY thing that does.
|
||||
- "android/**"
|
||||
# The workspace manifest and lockfile, which now live at the repo root.
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
# The whole frontend, not just the adapter/bridge seam: it is compiled INTO
|
||||
# the desktop binary, so any part of it changing means the shipped app is out
|
||||
# of date. Config and lockfile included — a dependency bump changes the bundle
|
||||
# as surely as a component does.
|
||||
- "frontend/**"
|
||||
- ".forgejo/workflows/desktop.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: desktop-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
# write (not read) so the tag build can publish a Release with the bundles
|
||||
@@ -51,9 +38,47 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# Does anything need building at all?
|
||||
#
|
||||
# ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing
|
||||
# the `paths:` filters that used to state the same fact a second time. They drifted
|
||||
# from it within a day: `packaging/` was added to the sets and not to the filters,
|
||||
# so the commit fixing a derivation bug never ran on the two lanes it fixed
|
||||
# (85ead4d). Note 3127 §3 warns about exactly that duplication.
|
||||
#
|
||||
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3).
|
||||
# Skipping because "the channel already serves this version" is indistinguishable
|
||||
# from "we derived a stale value that happens to match" unless something checks.
|
||||
decide:
|
||||
name: Build, or is the channel already serving this?
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
outputs:
|
||||
build: ${{ steps.d.outputs.build }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Derives a version — depth-1 is silently wrong (§6.1).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Decide
|
||||
id: d
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
case "$GITHUB_REF_NAME" in
|
||||
main) channel=stable ;;
|
||||
*) channel=dev ;;
|
||||
esac
|
||||
sh packaging/guard-forward.sh desktop "$channel"
|
||||
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
name: Tauri desktop (Linux)
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [decide]
|
||||
if: needs.decide.outputs.build == 'true'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||
@@ -64,6 +89,14 @@ jobs:
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||
# emitting something plausible, which is what turns this into a red lane
|
||||
# if it is ever dropped.
|
||||
fetch-depth: 0
|
||||
|
||||
# tauri's generate_context! embeds the built frontend at compile time, so the
|
||||
# frontend must exist before any cargo compile (clippy/test/build), not just
|
||||
@@ -123,8 +156,12 @@ jobs:
|
||||
else
|
||||
echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts."
|
||||
fi
|
||||
version="$(sh ../packaging/build-version.sh)"
|
||||
echo "Building version $version"
|
||||
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||
# separate value and arrives with the UI that shows it (#3181).
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
cargo tauri build \
|
||||
--config '{"build":{"beforeBuildCommand":""}}' \
|
||||
--config "{\"version\":\"$version\"}" \
|
||||
@@ -205,37 +242,40 @@ jobs:
|
||||
# failure, not as a green run with an empty artifact.
|
||||
if-no-files-found: error
|
||||
|
||||
# Tag builds only: publish a real, versioned Fabled-Git Release with the
|
||||
# AppImage + .deb attached — the stable fetch target the install script and
|
||||
# the in-app updater consume (Actions artifacts above are ephemeral/test).
|
||||
# Cutting the tag is the operator's action (rule 2); this only publishes a
|
||||
# Release for a tag that already exists. Dormant on dev/main pushes.
|
||||
- name: Publish release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash desktop/packaging/publish-release.sh
|
||||
|
||||
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
|
||||
# the updater has a permanent URL to read — Forgejo has no
|
||||
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
|
||||
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
|
||||
# are releases whose tag never moves, so the updater has a permanent URL to
|
||||
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
|
||||
# cannot be named in a URL.
|
||||
#
|
||||
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
|
||||
# M314 step 3 this job built on main and published nothing, so the stable
|
||||
# channel moved only when somebody cut a tag — that section's diagnostic
|
||||
# failing outright: main publishing was not sufficient for a user to receive
|
||||
# the build.
|
||||
#
|
||||
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
||||
# the secrets context isn't reliably available to step conditions. Publishing
|
||||
# bundles the app would then refuse to verify is worse than publishing nothing:
|
||||
# it looks like a working feed.
|
||||
- name: Publish to the dev channel
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
- name: Publish to the channel for this branch
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
RELEASE_TAG: dev
|
||||
RELEASE_PRERELEASE: "true"
|
||||
run: |
|
||||
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
|
||||
fi
|
||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
||||
# `prerelease` is true for dev so it does not read as a supported build,
|
||||
# and false for stable, which is the real thing.
|
||||
case "$GITHUB_REF_NAME" in
|
||||
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
|
||||
esac
|
||||
export RELEASE_TAG RELEASE_PRERELEASE
|
||||
echo "Publishing to the $RELEASE_TAG channel."
|
||||
bash desktop/packaging/publish-release.sh
|
||||
|
||||
# Windows installer, CROSS-COMPILED from Linux — there is no Windows build host.
|
||||
@@ -253,12 +293,21 @@ jobs:
|
||||
# built, not that it runs. A real-machine check stays mandatory before trusting it.
|
||||
windows:
|
||||
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
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||
# emitting something plausible, which is what turns this into a red lane
|
||||
# if it is ever dropped.
|
||||
fetch-depth: 0
|
||||
|
||||
# Same reason as the Linux job: generate_context! embeds the built frontend
|
||||
# at compile time, so it must exist before cargo runs.
|
||||
@@ -292,8 +341,12 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
version="$(sh ../packaging/build-version.sh)"
|
||||
echo "Building version $version"
|
||||
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||
# separate value and arrives with the UI that shows it (#3181).
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
updater='{}'
|
||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||
updater='{"bundle":{"createUpdaterArtifacts":true}}'
|
||||
@@ -317,35 +370,40 @@ jobs:
|
||||
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
# Publishes to the SAME release as the Linux job. Safe to run twice: the
|
||||
# script reuses an existing release (409) and nullglob means each job uploads
|
||||
# only the bundles present in its own workspace.
|
||||
- name: Publish release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash desktop/packaging/publish-release.sh
|
||||
|
||||
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
|
||||
# the updater has a permanent URL to read — Forgejo has no
|
||||
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
|
||||
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
|
||||
# are releases whose tag never moves, so the updater has a permanent URL to
|
||||
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
|
||||
# cannot be named in a URL.
|
||||
#
|
||||
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
|
||||
# M314 step 3 this job built on main and published nothing, so the stable
|
||||
# channel moved only when somebody cut a tag — that section's diagnostic
|
||||
# failing outright: main publishing was not sufficient for a user to receive
|
||||
# the build.
|
||||
#
|
||||
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
||||
# the secrets context isn't reliably available to step conditions. Publishing
|
||||
# bundles the app would then refuse to verify is worse than publishing nothing:
|
||||
# it looks like a working feed.
|
||||
- name: Publish to the dev channel
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
- name: Publish to the channel for this branch
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
RELEASE_TAG: dev
|
||||
RELEASE_PRERELEASE: "true"
|
||||
run: |
|
||||
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
|
||||
fi
|
||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
||||
# `prerelease` is true for dev so it does not read as a supported build,
|
||||
# and false for stable, which is the real thing.
|
||||
case "$GITHUB_REF_NAME" in
|
||||
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
|
||||
esac
|
||||
export RELEASE_TAG RELEASE_PRERELEASE
|
||||
echo "Publishing to the $RELEASE_TAG channel."
|
||||
bash desktop/packaging/publish-release.sh
|
||||
|
||||
# The updater manifest, written AFTER both bundle jobs — they run in separate
|
||||
@@ -359,12 +417,20 @@ jobs:
|
||||
manifest:
|
||||
name: Update manifest
|
||||
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
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||
# emitting something plausible, which is what turns this into a red lane
|
||||
# if it is ever dropped.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Write and publish latest.json
|
||||
env:
|
||||
@@ -376,23 +442,22 @@ jobs:
|
||||
echo "manifest to write. Add the secret to enable in-app updates."
|
||||
exit 0
|
||||
fi
|
||||
# The SAME helper the bundles were built with — a second derivation here
|
||||
# could drift, and a manifest whose version doesn't match the binary it
|
||||
# points at is an updater that never settles.
|
||||
version="$(sh desktop/packaging/build-version.sh)"
|
||||
if [ "${GITHUB_REF_NAME}" = "dev" ]; then
|
||||
export RELEASE_TAG=dev
|
||||
export RELEASE_NOTES="Development build from ${GITHUB_SHA}"
|
||||
# Rolling channel: drop the previous build's bundles once the manifest
|
||||
# points at this one. Nothing can reach them, and they're ~100 MB a push.
|
||||
export PRUNE_OLD_ASSETS=true
|
||||
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
||||
else
|
||||
export RELEASE_TAG="${GITHUB_REF_NAME}"
|
||||
export RELEASE_NOTES="ThoughtSync ${GITHUB_REF_NAME}"
|
||||
# Twice: once onto the versioned release itself, and once onto the
|
||||
# permanent `stable` pointer the app actually reads. Same manifest both
|
||||
# times — its URLs point at the versioned assets either way.
|
||||
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
||||
APP_VERSION="$version" MANIFEST_TAG=stable bash desktop/packaging/write-manifest.sh
|
||||
fi
|
||||
# The SAME helper AND the same request the bundles were built with — a
|
||||
# second derivation here could drift, and a manifest whose version doesn't
|
||||
# match the binary it points at is an updater that never settles. It must
|
||||
# be `key`: this value is matched against bundle filenames.
|
||||
version="$(sh packaging/version.sh key desktop)"
|
||||
# Both channels are rolling: the manifest lands on the same release that
|
||||
# holds the bundles, and the previous build's bundles are dropped once it
|
||||
# points at this one. Nothing can reach them, and they are ~100 MB a push.
|
||||
#
|
||||
# No tag arm any more. A `v*` tag does not reach this workflow at all — it
|
||||
# triggers release.yml, which writes a changelog and builds nothing.
|
||||
case "${GITHUB_REF_NAME}" in
|
||||
main) export RELEASE_TAG=stable
|
||||
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
|
||||
*) export RELEASE_TAG=dev
|
||||
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
|
||||
esac
|
||||
export PRUNE_OLD_ASSETS=true
|
||||
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
- Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`.
|
||||
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
|
||||
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
|
||||
`:<git-sha>` (immutable, for pinning / rollback).
|
||||
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev`
|
||||
build) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are
|
||||
no version-shaped tags: nothing pins one, and the build reports its own version
|
||||
at `/api/config` and `/health`.
|
||||
- **Putting it on the public internet:** there are four things to do first — close
|
||||
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
|
||||
port, and back up the attachment volume as well as the database. See
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -7,6 +7,9 @@
|
||||
this permission never exercised.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Only to answer "is this connection metered?" before the app downloads its own
|
||||
update in the background. Normal permission, no prompt, no location. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!--
|
||||
Four more permissions are NOT declared here and still reach the merged
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentSender
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
@@ -62,6 +64,30 @@ object AppUpdate {
|
||||
.setData(Uri.fromParts("package", context.packageName, null))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
/**
|
||||
* Whether this is wifi somebody is not paying by the megabyte for.
|
||||
*
|
||||
* The app fetches its own update in the background, and fifty-odd megabytes over
|
||||
* mobile data is a bill nobody agreed to. Anywhere else it simply waits — the
|
||||
* update is found, nothing is downloaded, and nothing is said until it can be.
|
||||
*
|
||||
* BOTH conditions, deliberately. Wifi alone would still download over a tethered
|
||||
* hotspot, which is mobile data wearing a different hat and the exact bill this
|
||||
* avoids. Unmetered alone would download over an unmetered cellular plan, which
|
||||
* is not what "on wifi" means to the person who asked for it.
|
||||
*
|
||||
* Every uncertain answer is `false`: the cautious one costs nothing.
|
||||
*/
|
||||
fun onWifi(context: Context): Boolean {
|
||||
val caps =
|
||||
context
|
||||
.getSystemService(ConnectivityManager::class.java)
|
||||
?.let { manager -> manager.activeNetwork?.let(manager::getNetworkCapabilities) }
|
||||
return caps != null &&
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
/** Where a download goes: app-private, so no storage permission is involved. */
|
||||
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||
import com.fabledsword.thoughtsync.ui.BoardSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardUpdate
|
||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
||||
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||
@@ -141,14 +141,13 @@ private fun App(
|
||||
val sync: SyncViewModel =
|
||||
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
|
||||
|
||||
// Sheet and screen visibility are view STATE, not view-model state: they are
|
||||
// about what is on the display, and nothing in the store cares.
|
||||
// Screen visibility is view STATE, not view-model state: it is about what is on
|
||||
// the display, and nothing in the store cares. Saveable so a rotation does not
|
||||
// close it.
|
||||
//
|
||||
// Saveable, though: `remember` alone meant rotating the phone closed whatever
|
||||
// was open and took the half-written note in the capture sheet with it. The
|
||||
// editor never had that problem because the note it is on lives in a view
|
||||
// model; these two are the only screen state that did not.
|
||||
var composing by rememberSaveable { mutableStateOf(false) }
|
||||
// The capture sheet used to keep its own flag here too. It is gone: the + button
|
||||
// opens the editor on an unsaved draft, so writing a note and editing one are the
|
||||
// same surface with the same toolbar.
|
||||
var showingSync by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||
@@ -156,6 +155,7 @@ private fun App(
|
||||
var automatic by remember { mutableStateOf(settings.automatic) }
|
||||
|
||||
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
|
||||
AutomaticUpdate(linked = sync.state.linked, onCheck = update::checkInBackground)
|
||||
|
||||
val editing = board.state.editing
|
||||
val screen =
|
||||
@@ -192,6 +192,7 @@ private fun App(
|
||||
NoteEditorScreen(
|
||||
// Non-null by construction: `screen` is EDITOR only when it is.
|
||||
note = requireNotNull(editing) { "the editor screen needs a note" },
|
||||
sessionKey = board.state.editingSession,
|
||||
labels = board.state.labels,
|
||||
saving = board.state.saving,
|
||||
error = board.state.error,
|
||||
@@ -218,20 +219,28 @@ private fun App(
|
||||
),
|
||||
onOpenSync = { showingSync = true },
|
||||
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,
|
||||
)
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
@@ -37,6 +40,11 @@ import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
@@ -44,7 +52,11 @@ import androidx.compose.material3.pulltorefresh.pullToRefresh
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -64,10 +76,52 @@ fun BoardScreen(
|
||||
onOpenSync: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||
onNoteAction: (Note, EditorAction) -> Unit,
|
||||
update: BoardUpdate?,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbars = remember { SnackbarHostState() }
|
||||
|
||||
// Held HERE rather than on the card. A card lives in a lazy grid and is disposed
|
||||
// the moment it scrolls out of view, which would take its dialog down with it —
|
||||
// and the board can scroll under an open dialog.
|
||||
var confirmingDelete by remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
// Resolved in composition, not inside the coroutine: `stringResource` is a
|
||||
// composable read and cannot be called from a suspend block.
|
||||
val trashedMessage = stringResource(R.string.board_trashed)
|
||||
val undoLabel = stringResource(R.string.board_undo)
|
||||
|
||||
// Trash gets an UNDO rather than a confirmation, and the two are not
|
||||
// interchangeable. A long press is a gesture you can make by accident — resting a
|
||||
// thumb while reading is enough — so the mistake worth designing for is the one
|
||||
// nobody meant to make, and a dialog only helps someone who is paying attention
|
||||
// in the moment they were not. Trash is already recoverable; the snackbar just
|
||||
// says so where it happened, instead of leaving you to find the Trash view and
|
||||
// work out which note went missing.
|
||||
//
|
||||
// Delete forever keeps its dialog. That one does not undo.
|
||||
val onCardAction: (Note, EditorAction) -> Unit = { note, action ->
|
||||
onNoteAction(note, action)
|
||||
if (action == EditorAction.Trash) {
|
||||
scope.launch {
|
||||
val outcome =
|
||||
snackbars.showSnackbar(
|
||||
message = trashedMessage,
|
||||
actionLabel = undoLabel,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
// `note` is the pre-trash copy and deliberately so: Restore only needs
|
||||
// its id, and the id is the one thing trashing does not change.
|
||||
if (outcome == SnackbarResult.ActionPerformed) {
|
||||
onNoteAction(note, EditorAction.Restore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -88,6 +142,22 @@ fun BoardScreen(
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
// The IME, added to what the Scaffold already insets for. `enableEdgeToEdge`
|
||||
// makes the manifest's `adjustResize` a no-op on API 30+, so nothing resizes
|
||||
// for the keyboard unless the app asks — and `ScaffoldDefaults.contentWindowInsets`
|
||||
// is systemBars, which the IME is not part of. The Scaffold positions the FAB
|
||||
// AND the snackbar host from this value, so without it both sit behind the
|
||||
// keyboard whenever the search field has focus. That is not theoretical: the
|
||||
// undo on a trashed search hit is exactly the control you cannot reach.
|
||||
//
|
||||
// `union` rather than `add` — the two are the same edge, not two stacked ones.
|
||||
// Adding them would inset by the navigation bar a second time underneath a
|
||||
// keyboard that already covers it.
|
||||
//
|
||||
// One owner for the edge, as with the search bar's missing statusBarsPadding:
|
||||
// set here, the content Column gets it through `padding` and must not repeat it.
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||
snackbarHost = { SnackbarHost(snackbars) },
|
||||
floatingActionButton = {
|
||||
// The + is the ONLY way in, by design: one obvious target rather
|
||||
// than a capture bar and a button competing for the same job.
|
||||
@@ -117,6 +187,17 @@ fun BoardScreen(
|
||||
ErrorBanner(message = message, onDismiss = sync.onDismissError)
|
||||
}
|
||||
|
||||
// Below the failures and above the notes: an update is worth saying,
|
||||
// and never worth saying before a note failed to save.
|
||||
update?.let {
|
||||
UpdateBanner(
|
||||
version = it.version,
|
||||
busy = it.busy,
|
||||
onInstall = it.onInstall,
|
||||
onDismiss = it.onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
// Only where someone is already thinking about reminders. On the
|
||||
// main board it would nag people who have never set one.
|
||||
if (state.destination == Destination.Reminders) ReminderNotice()
|
||||
@@ -140,7 +221,14 @@ fun BoardScreen(
|
||||
when {
|
||||
state.loading -> LoadingBoard()
|
||||
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
|
||||
// `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,
|
||||
)
|
||||
|
||||
/**
|
||||
* The waiting app update, or null when there is nothing to say.
|
||||
*
|
||||
* A holder rather than five loose parameters, for the same reason [BoardSync] is one:
|
||||
* `version` and a pair of booleans as positional arguments could be swapped with
|
||||
* nothing to catch it.
|
||||
*
|
||||
* Null covers every reason there is nothing to show — unlinked, up to date, found but
|
||||
* not yet downloaded, dismissed for this sitting — so the board never has to know
|
||||
* which.
|
||||
*/
|
||||
data class BoardUpdate(
|
||||
val version: String,
|
||||
/** An install is in flight — the banner stays and reports it. */
|
||||
val busy: Boolean,
|
||||
val onInstall: () -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* A search field IS the top bar, following the phone convention rather than the
|
||||
* desktop's title-plus-sidebar.
|
||||
@@ -323,6 +440,9 @@ private fun DrawerRow(
|
||||
private fun NoteBoard(
|
||||
notes: List<Note>,
|
||||
onOpenNote: (Note) -> Unit,
|
||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||
onNoteAction: (Note, EditorAction) -> Unit,
|
||||
onConfirmDelete: (Note) -> Unit,
|
||||
) {
|
||||
LazyVerticalStaggeredGrid(
|
||||
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
|
||||
@@ -336,7 +456,13 @@ private fun NoteBoard(
|
||||
// rebuilding them — and so a newly captured note slides in instead of
|
||||
// making every card below it flicker.
|
||||
items(items = notes, key = { it.id }) { note ->
|
||||
NoteCard(note = note, onOpen = { onOpenNote(note) })
|
||||
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.
|
||||
*/
|
||||
val editing: Note? = null,
|
||||
/**
|
||||
* Bumped each time the editor is opened on a DIFFERENT note, and deliberately
|
||||
* not when the note it is already on changes.
|
||||
*
|
||||
* The editor keys its text field on this rather than on `editing.id`, because a
|
||||
* draft's id changes the instant it is first saved — and re-keying on that would
|
||||
* reset the field to whatever the store just returned, discarding anything typed
|
||||
* during the write. That is a data-loss bug rather than a flicker.
|
||||
*/
|
||||
val editingSession: Long = 0,
|
||||
) {
|
||||
/** Search overrides the destination while there is a query to run. */
|
||||
val searching: Boolean get() = query.isNotBlank()
|
||||
@@ -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 ──────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -238,12 +210,111 @@ class BoardViewModel(
|
||||
fun openNoteById(id: String) {
|
||||
viewModelScope.launch {
|
||||
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) {
|
||||
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,
|
||||
action: EditorAction,
|
||||
) {
|
||||
if (note.id == DRAFT_ID) {
|
||||
onDraftAction(note, action)
|
||||
return
|
||||
}
|
||||
val id = note.id
|
||||
when (action) {
|
||||
EditorAction.Close -> state = state.copy(editing = null)
|
||||
EditorAction.DismissError -> dismissError()
|
||||
|
||||
// Saved on close rather than per keystroke, so a session of typing
|
||||
// costs one write and one revision snapshot.
|
||||
// Sent on an idle debounce while typing, and again on close. Writing
|
||||
// this often is affordable because a body write no longer snapshots a
|
||||
// revision — the core keeps one per editing session, not one per save.
|
||||
is EditorAction.SaveText ->
|
||||
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
||||
|
||||
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
|
||||
|
||||
// Pinning re-sorts the board rather than emptying it, and on a phone
|
||||
// you often pin while still reading — so unlike the three below, it
|
||||
// deliberately leaves the editor open.
|
||||
@@ -302,20 +376,6 @@ class BoardViewModel(
|
||||
null
|
||||
}
|
||||
|
||||
// An empty first item: the checklist editor appears the moment the note
|
||||
// has one, and an empty row is what someone can type straight into.
|
||||
EditorAction.AddChecklist -> mutate { it.addItem(id, "") }
|
||||
|
||||
is EditorAction.AddItem ->
|
||||
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
|
||||
mutate { it.addItem(id, text) }
|
||||
}
|
||||
is EditorAction.SetItemChecked ->
|
||||
mutate { it.setItemChecked(id, action.itemId, action.checked) }
|
||||
is EditorAction.SetItemText ->
|
||||
mutate { it.setItemText(id, action.itemId, action.text) }
|
||||
is EditorAction.DeleteItem -> mutate { it.deleteItem(id, action.itemId) }
|
||||
|
||||
is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
|
||||
|
||||
is EditorAction.CreateLabel ->
|
||||
@@ -351,9 +411,10 @@ class BoardViewModel(
|
||||
/**
|
||||
* The one path every store mutation takes.
|
||||
*
|
||||
* Each core mutation returns the reloaded note, which goes straight into
|
||||
* [BoardState.editing] so an open editor shows its own change without a
|
||||
* re-query. The BOARD list is then reloaded rather than patched in place:
|
||||
* Each core mutation returns the reloaded note, which refreshes
|
||||
* [BoardState.editing] so an OPEN editor shows its own change without a
|
||||
* re-query — and does nothing at all when the editor is closed, because that
|
||||
* field doubles as "which screen is up". The BOARD list is then reloaded rather than patched in place:
|
||||
* pinning re-sorts it, archiving removes the note from it, and adding a label
|
||||
* can move it in or out of a label view — a splice would have to reimplement
|
||||
* the core's ordering and membership rules in Kotlin to get any of that right.
|
||||
@@ -389,7 +450,12 @@ class BoardViewModel(
|
||||
withContext(Dispatchers.IO) { onRemindersChanged() }
|
||||
state.copy(
|
||||
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,
|
||||
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() {
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// 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
|
||||
// findable without anyone being asked to name it. A checklist is added afterwards,
|
||||
// in the editor — it is something a note HAS, not a different thing to capture.
|
||||
NoteDraft(body = content, 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,
|
||||
) : EditorAction
|
||||
|
||||
data class SetColor(
|
||||
val color: String,
|
||||
) : EditorAction
|
||||
|
||||
/**
|
||||
* Give this note a checklist.
|
||||
*
|
||||
* Not a conversion — a note HAS a checklist rather than BEING one (M13 step 2),
|
||||
* so nothing moves and nothing is swapped: the body stays exactly where it is and
|
||||
* the note gains a first, empty item for someone to type into.
|
||||
*/
|
||||
data object AddChecklist : EditorAction
|
||||
|
||||
data class SetPinned(
|
||||
val pinned: Boolean,
|
||||
) : EditorAction
|
||||
@@ -52,23 +39,12 @@ sealed interface EditorAction {
|
||||
|
||||
data object DeleteForever : EditorAction
|
||||
|
||||
data class AddItem(
|
||||
val text: String,
|
||||
) : EditorAction
|
||||
|
||||
data class SetItemChecked(
|
||||
val itemId: String,
|
||||
val checked: Boolean,
|
||||
) : EditorAction
|
||||
|
||||
data class SetItemText(
|
||||
val itemId: String,
|
||||
val text: String,
|
||||
) : EditorAction
|
||||
|
||||
data class DeleteItem(
|
||||
val itemId: String,
|
||||
) : EditorAction
|
||||
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the
|
||||
// body, so adding, renaming, ticking or deleting one is editing text — which the
|
||||
// editor already does, through SaveText, with the same autosave and the same
|
||||
// revision window as any other edit. Routing them through the store would have
|
||||
// meant the store handing back a note whose body disagreed with the field the
|
||||
// person was typing in.
|
||||
|
||||
/**
|
||||
* The note's MANUAL labels, replacing whatever was there.
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -39,7 +45,19 @@ import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
|
||||
/**
|
||||
* The editor's action bar, at the bottom where a thumb already is.
|
||||
* The editor's action bar, along the top of the surface.
|
||||
*
|
||||
* It sits exactly where the capture sheet's drag handle used to. The handle cost
|
||||
* this strip of screen and did nothing that a back gesture does not already do, so
|
||||
* the strip carries the actions instead.
|
||||
*
|
||||
* Top rather than bottom, now that this one surface is used for WRITING as well as
|
||||
* editing: the keyboard owns the bottom of the display for most of a note's life,
|
||||
* so a bar down there spends its time riding on the IME. That is the right place
|
||||
* for a send button and the wrong one for a colour picker, which is reached for
|
||||
* between thoughts rather than at the end of them. The cost is honest — the top of
|
||||
* a phone is further from a thumb than the bottom — and it buys a bar that does not
|
||||
* move while you type.
|
||||
*
|
||||
* The three affordances with a permanent slot are the ones reached for while still
|
||||
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
|
||||
@@ -54,57 +72,184 @@ import com.fabledsword.thoughtsync.core.Note
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditorBottomBar(
|
||||
fun EditorTopBar(
|
||||
note: Note,
|
||||
readOnly: Boolean,
|
||||
tint: NoteTint,
|
||||
onClose: () -> Unit,
|
||||
onStartChecklist: () -> Unit,
|
||||
onPicker: (Picker) -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
BottomAppBar(containerColor = tint.background(dark)) {
|
||||
if (!readOnly) {
|
||||
// A dot in the note's CURRENT colour rather than a palette icon: it
|
||||
// shows what the colour is as well as what the button does.
|
||||
IconButton(onClick = { onPicker(Picker.COLOR) }) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH_DOT)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.border(dark), CircleShape),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
// The only way out, and the only thing that needed a "save" button
|
||||
// before writes became continuous. Leaving IS saving now, which is what
|
||||
// the line in the bottom corner is there to say out loud.
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
Icons.Filled.Notifications,
|
||||
contentDescription = stringResource(R.string.editor_reminder),
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
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
|
||||
// left to add that the checklist's own "+" row doesn't do better.
|
||||
if (note.items.isEmpty()) {
|
||||
IconButton(onClick = { onAction(EditorAction.AddChecklist) }) {
|
||||
},
|
||||
actions = {
|
||||
if (!readOnly) {
|
||||
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
|
||||
Icon(
|
||||
Icons.Filled.Notifications,
|
||||
contentDescription = stringResource(R.string.editor_reminder),
|
||||
)
|
||||
}
|
||||
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
|
||||
// a checklist is text now (M304), so there is no section to be
|
||||
// already-showing and no reason a second list cannot start further
|
||||
// down the same note.
|
||||
IconButton(onClick = onStartChecklist) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.List,
|
||||
contentDescription = stringResource(R.string.editor_add_checklist),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OverflowMenu(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
onPicker = onPicker,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
onAction = onAction,
|
||||
)
|
||||
},
|
||||
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
|
||||
// Material derives a bar's content colour from its container via
|
||||
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and
|
||||
// returns Unspecified for anything else. The card surface is a plain constant
|
||||
// and not a role, so the icons drew with no colour filter: black vectors on a
|
||||
// near-black bar, a toolbar that rendered the whole time and was invisible in
|
||||
// dark mode. STILL TRUE with one neutral surface — it is the same kind of
|
||||
// value, so this stays exactly as it is.
|
||||
//
|
||||
// onSurface for the actions too, not the default onSurfaceVariant: the bar has
|
||||
// to read against the card rather than the board, and the muted variant does
|
||||
// not have the contrast to spare.
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = noteCardSurface(dark),
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f))
|
||||
|
||||
OverflowMenu(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
onPicker = onPicker,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
onAction = onAction,
|
||||
/**
|
||||
* The footer: when the note was last written, and the way out.
|
||||
*
|
||||
* **Where the note stands.** There is no save button, and there should not be — a
|
||||
* note is saved continuously, so a button offering to do what already happened is a
|
||||
* lie with a tap attached. But that left nothing on screen saying the work is safe,
|
||||
* and "closing this keeps it" is not a thing anyone should have to be told twice. So
|
||||
* the state says it, as a fact rather than an instruction: Not saved yet → Saving… →
|
||||
* Edited just now is the whole lifecycle, and someone who watches it once never has
|
||||
* to wonder again.
|
||||
*
|
||||
* **The way out.** Down here because of where hands are. Moving the toolbar to the
|
||||
* top took the back arrow with it, which left the only exit from a full-screen
|
||||
* editor in the top-left corner — the furthest point on the display from a
|
||||
* right-handed thumb, and reached over the whole note to get to. The operator hit
|
||||
* that on the first device pass and was right to. So the exit lives in the bottom
|
||||
* corner, which with the keyboard up sits directly above it.
|
||||
*
|
||||
* The top-left arrow stays as well. Two affordances for one action is usually
|
||||
* clutter, but this is the case that earns it: the arrow is what habit, the system
|
||||
* back gesture and TalkBack all expect of a full-screen surface, and removing it
|
||||
* would strand the reflex to strike a duplicate that costs one icon slot.
|
||||
*
|
||||
* A checkmark, at the operator's ask. I had shipped the word "Done" here on the
|
||||
* argument that a tick in a NOTES app reads as a checklist item; overruled, and the
|
||||
* filled treatment is what settles it — a tonal button in the note's own colour is
|
||||
* plainly a control, where a bare glyph beside a checklist would not be. It carries
|
||||
* "Done" as its content description, so the reasoning survives where it actually
|
||||
* mattered: read aloud.
|
||||
*
|
||||
* [DateUtils] rather than a hand-rolled formatter: it is localised, it already
|
||||
* knows the difference between minutes, hours and yesterday, and getting plurals
|
||||
* right in every language is not this app's problem to solve twice.
|
||||
*/
|
||||
@Composable
|
||||
fun EditorFooter(
|
||||
updatedAt: String?,
|
||||
saving: Boolean,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
// Rides above the keyboard, like the bar that used to be here. The
|
||||
// content Column deliberately does not also inset for the IME:
|
||||
// Scaffold measures this row at its lifted height and passes the
|
||||
// inset down.
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
// The gap is what keeps the timestamp from reading as the button's label.
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = savedLabel(updatedAt, saving),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FilledTonalIconButton(
|
||||
onClick = onClose,
|
||||
// The BRAND, matching the board's compose FAB — the app's one existing
|
||||
// statement of "this is the affirmative action here", now reused rather
|
||||
// than a second one invented.
|
||||
//
|
||||
// This wore the note's own tint until M315, on the argument that it would
|
||||
// otherwise be the one element on a tinted card ignoring the tint. There is
|
||||
// no tint to ignore any more, and the alternative — Material's default
|
||||
// secondaryContainer — is a baseline M3 colour this theme never sets, so
|
||||
// taking the default would put an off-brand lilac in the corner of the
|
||||
// editor.
|
||||
colors =
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.editor_done))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The three things the footer can be saying, in the order it says them. */
|
||||
@Composable
|
||||
private fun savedLabel(
|
||||
updatedAt: String?,
|
||||
saving: Boolean,
|
||||
): String {
|
||||
// No timestamp means no row yet — a draft opened by + and not typed into.
|
||||
val at = updatedAt?.let { epochMillis(it) }
|
||||
val now = System.currentTimeMillis()
|
||||
return when {
|
||||
saving -> stringResource(R.string.editor_saving)
|
||||
at == null -> stringResource(R.string.editor_unsaved)
|
||||
// DateUtils rounds anything under its minimum resolution to "0 minutes
|
||||
// ago" — which is both odd-looking and precisely the moment this line is
|
||||
// on screen for, since it is the moment right after a save lands.
|
||||
now - at < DateUtils.MINUTE_IN_MILLIS ->
|
||||
stringResource(R.string.editor_edited, stringResource(R.string.editor_just_now))
|
||||
else ->
|
||||
stringResource(
|
||||
R.string.editor_edited,
|
||||
DateUtils.getRelativeTimeSpanString(at, now, DateUtils.MINUTE_IN_MILLIS).toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -177,19 +304,23 @@ fun EditorLabelRow(
|
||||
val dark = isSystemInDarkTheme()
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
note.labels.forEach { label ->
|
||||
val tint = noteTint(label.color)
|
||||
val tint = labelTintFor(label.name, label.color)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
) {
|
||||
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,
|
||||
color = tint.chipForeground(dark),
|
||||
color = tint.tagInk(dark),
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), CircleShape)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
if (label.viaTag) {
|
||||
@@ -263,6 +394,5 @@ fun EditorReminderRow(
|
||||
}
|
||||
}
|
||||
|
||||
private val SWATCH_DOT = 22.dp
|
||||
private const val SNOOZE_HOUR = 60L
|
||||
private const val SNOOZE_DAY = 1440L
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -13,21 +9,16 @@ import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
@@ -42,7 +33,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -57,80 +47,17 @@ import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
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
|
||||
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
|
||||
// the note still visible above it — which matters when the choice you are making
|
||||
// is about the thing you are looking at.
|
||||
|
||||
/** The note palette, as swatches. Order and colours come from [NOTE_TINTS]. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ColorSheet(
|
||||
selected: String,
|
||||
onPick: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
SheetTitle(R.string.color_picker_title)
|
||||
// Chunked into fixed rows rather than a flow layout: ten swatches
|
||||
// always lay out as two rows of five on every phone width, and a flow
|
||||
// would reshuffle them between devices for no gain.
|
||||
NOTE_TINTS.entries.chunked(SWATCHES_PER_ROW).forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
row.forEach { (key, tint) ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH_SIZE)
|
||||
.clip(CircleShape)
|
||||
.background(tint.background(dark))
|
||||
.border(
|
||||
// The selected swatch gets a heavier ring
|
||||
// as well as a tick: on the pale tints the
|
||||
// tick alone is nearly invisible.
|
||||
if (key == selected) 2.dp else 1.dp,
|
||||
if (key == selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
tint.border(dark)
|
||||
},
|
||||
CircleShape,
|
||||
).clickable(onClickLabel = tint.label) { onPick(key) },
|
||||
) {
|
||||
if (key == selected) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = tint.label,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pad a short final row so its swatches line up with the row
|
||||
// above instead of spreading across the full width.
|
||||
repeat(SWATCHES_PER_ROW - row.size) {
|
||||
Box(modifier = Modifier.size(SWATCH_SIZE))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every label, ticked where it is on the note.
|
||||
*
|
||||
@@ -461,8 +388,6 @@ private val RECURRENCE_RULES: List<Pair<String?, Int>> =
|
||||
"yearly" to R.string.recurrence_yearly,
|
||||
)
|
||||
|
||||
private const val SWATCHES_PER_ROW = 5
|
||||
private const val EVENING_HOUR = 18
|
||||
private const val MORNING_HOUR = 8
|
||||
private val SWATCH_SIZE = 44.dp
|
||||
private val LABEL_LIST_MAX_HEIGHT = 320.dp
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.fabledsword.thoughtsync.ui
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -12,122 +14,352 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.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.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.ChecklistItem
|
||||
import com.fabledsword.thoughtsync.core.BodyItem
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.NoteLabel
|
||||
import com.fabledsword.thoughtsync.core.bodyTags
|
||||
import com.fabledsword.thoughtsync.core.checklistItems
|
||||
|
||||
@Composable
|
||||
fun NoteCard(
|
||||
note: Note,
|
||||
onOpen: () -> Unit,
|
||||
onToggleItem: (Int, Boolean) -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(note.color)
|
||||
val haptics = LocalHapticFeedback.current
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
// Clipped BEFORE clickable, so the ripple is bounded by the card's
|
||||
// rounded corners instead of a rectangle overhanging them.
|
||||
.clip(RoundedCornerShape(CARD_RADIUS))
|
||||
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
|
||||
.background(tint.background(dark))
|
||||
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// Body then checklist, in order — a note can carry both (M13 step 2), and
|
||||
// nothing above them: the first line of the body IS the note's name, at the
|
||||
// same weight as the rest of it (M13 steps 3 and 4).
|
||||
if (note.body.isNotBlank()) {
|
||||
Text(
|
||||
text = note.body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = MAX_PREVIEW_LINES,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (note.items.isNotEmpty()) {
|
||||
if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp))
|
||||
Checklist(items = note.items)
|
||||
// NAMED rather than written inline in the chain below, and not for taste: ktlint's
|
||||
// chain-method-continuation wants the next `.` glued to the closing paren of a
|
||||
// multiline element — `).background(…)` — which is worse to read than a modifier
|
||||
// with a name. Every other multiline element in this codebase happens to be last
|
||||
// in its chain, so this is the first place the rule bites.
|
||||
val opening =
|
||||
Modifier.combinedClickable(
|
||||
onClickLabel = stringResource(R.string.board_open_note),
|
||||
onLongClickLabel = stringResource(R.string.board_note_actions),
|
||||
onLongClick = {
|
||||
// Fired HERE rather than when the menu appears. A long press is
|
||||
// confirmed by the system before the popup has laid out, and the whole
|
||||
// point of the buzz is to say "that registered" at the moment your
|
||||
// finger has been still long enough — a menu that arrives with no tick
|
||||
// under it reads as a phone that missed the gesture and then changed
|
||||
// its mind.
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
menuOpen = true
|
||||
},
|
||||
onClick = onOpen,
|
||||
)
|
||||
|
||||
// The Box exists only to anchor the menu. A DropdownMenu is a popup and takes no
|
||||
// space, so the card's size is still the Column's.
|
||||
Box {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
// Depth, not the boundary — the edge below is that. 1dp: enough to
|
||||
// separate a white card from a #fafafa board, and the web's own
|
||||
// `shadow-sm` is the value it is matching.
|
||||
.shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS))
|
||||
// Clipped BEFORE the click modifier, so the ripple is bounded by the
|
||||
// card's rounded corners instead of a rectangle overhanging them —
|
||||
// and BEFORE padding, so the padded edge is still a tap target.
|
||||
.clip(RoundedCornerShape(CARD_RADIUS))
|
||||
.then(opening)
|
||||
// ONE surface and ONE edge on every card, both neutral, neither
|
||||
// asking the note anything. See noteCardSurface and CARD_EDGE_DARK.
|
||||
.background(noteCardSurface(dark))
|
||||
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// TAGS FIRST. They used to sit under everything else, which on a tall note put
|
||||
// the one thing that says what a note IS below the fold of a glance. A board is
|
||||
// scanned, not read, and the answer to "which of these is about the thing I am
|
||||
// looking for" should be the first thing the eye lands on rather than the last.
|
||||
//
|
||||
// Above the body rather than beside it, because the body's first line is the
|
||||
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete
|
||||
// with the thing that identifies the note. A row of its own costs one line and
|
||||
// only on notes that have tags at all.
|
||||
//
|
||||
// ONLY the labels whose text is not still in the note. `via_tag` means exactly
|
||||
// "backed by body text" since M311, so a chip for one printed the same tag
|
||||
// twice — once where it was typed, once up here — and the card was carrying
|
||||
// furniture for information it was already showing. A tag left in prose is
|
||||
// tinted in place instead; see [tintTags]. What reaches this row is what the
|
||||
// body cannot say: a tag lifted off its own line, and a label added by hand.
|
||||
val chips = note.labels.filterNot { it.viaTag }
|
||||
if (chips.isNotEmpty()) {
|
||||
LabelChips(labels = chips)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Body then checklist, in order — a note can carry both (M13 step 2). The
|
||||
// first line of the body IS the note's name, at the same weight as the rest of
|
||||
// it (M13 steps 3 and 4).
|
||||
if (note.body.isNotBlank()) {
|
||||
NoteBody(note = note, onToggleItem = onToggleItem)
|
||||
}
|
||||
|
||||
// A note with nothing in it still has to occupy the board legibly — otherwise
|
||||
// it reads as a rendering bug.
|
||||
if (note.body.isBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.board_empty_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
note.remindAt?.let { at ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReminderChip(instant = at, recurrence = note.recurrence)
|
||||
}
|
||||
}
|
||||
|
||||
// A note with no body and no items still has to occupy the board legibly —
|
||||
// otherwise it reads as a rendering bug.
|
||||
if (note.body.isBlank() && note.items.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.board_empty_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
NoteMenu(
|
||||
note = note,
|
||||
expanded = menuOpen,
|
||||
onDismiss = { menuOpen = false },
|
||||
onAction = onAction,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LabelChips(labels = note.labels)
|
||||
}
|
||||
|
||||
note.remindAt?.let { at ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReminderChip(instant = at, recurrence = note.recurrence)
|
||||
/**
|
||||
* What you can do to a note without opening it.
|
||||
*
|
||||
* The board used to have none of this, and the operator's read of that was not "the
|
||||
* actions are in the editor" — it was *"there are no long hold context menus in the
|
||||
* app I have no way to delete notes."* Trash was three interactions deep (open, ⋮,
|
||||
* Move to trash), and on a phone that is far enough from the gesture people reach
|
||||
* for that it may as well not exist.
|
||||
*
|
||||
* **The same items as the editor's overflow, in the same words, from the same string
|
||||
* resources.** A note has one vocabulary of things that can be done to it, and two
|
||||
* surfaces that named them differently would be describing two different apps. It
|
||||
* dispatches [EditorAction] for the same reason — `BoardViewModel.onEditorAction` is
|
||||
* already the exhaustive dispatcher for every one of them, so the board reuses the
|
||||
* seam rather than growing a parallel one that could drift.
|
||||
*
|
||||
* **Gated on the NOTE, not on the destination.** `note.trashed` is what the editor
|
||||
* gates its own read-only mode on, and it is the only reading that survives the views
|
||||
* that mix piles: Reminders cuts across archived and active alike, and a search hits
|
||||
* whatever matches. A menu that offered "Move to trash" on a note already in the
|
||||
* trash would be offering to do something twice.
|
||||
*
|
||||
* **Colour is absent**, though #2946 suggested it. It was left out because `note.color`
|
||||
* was already scheduled for removal; M315 removed it. There is no colour to set on a
|
||||
* note any more — a card is one neutral surface and the only coloured thing on a board
|
||||
* is a tag — so the row this menu never grew is a row that could not exist.
|
||||
*
|
||||
* Labels are absent too, for a duller reason: the picker they open is editor state,
|
||||
* and hoisting it to the board is a bigger change than the friction actually reported.
|
||||
*/
|
||||
@Composable
|
||||
private fun NoteMenu(
|
||||
note: Note,
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
if (note.trashed) {
|
||||
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
|
||||
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
|
||||
} else {
|
||||
MenuItem(
|
||||
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
|
||||
onDismiss,
|
||||
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
||||
MenuItem(
|
||||
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
||||
onDismiss,
|
||||
) { onAction(EditorAction.SetArchived(!note.archived)) }
|
||||
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The note's body, with its checklist drawn where it actually sits.
|
||||
*
|
||||
* Rendered line by line rather than as one block of text, because an item is a line
|
||||
* of the body now (M304) and a card that showed the prose and then the list would put
|
||||
* every list in the wrong place — and, since the body already contains those lines,
|
||||
* would show each one twice.
|
||||
*
|
||||
* Which lines are items is asked of the core rather than matched here. The grammar is
|
||||
* already written three times; a fourth in Compose would be a fourth place for a
|
||||
* checklist to change shape when it syncs.
|
||||
*/
|
||||
@Composable
|
||||
private fun 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)) {
|
||||
items.take(MAX_CHECKLIST_ROWS).forEach { item ->
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
// A glyph rather than a real Checkbox: the card is a PREVIEW, and
|
||||
// a live control here would invite taps that the board cannot yet
|
||||
// honour. It becomes interactive with the editor.
|
||||
Text(
|
||||
text = if (item.checked) "☑" else "☐",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = item.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
||||
color =
|
||||
if (item.checked) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
|
||||
val found = itemAtLine[n]
|
||||
when {
|
||||
found != null ->
|
||||
ChecklistRow(note, found.second) { onToggleItem(found.first, !found.second.checked) }
|
||||
// Kept as a gap rather than dropped: it is the paragraph break
|
||||
// somebody typed, and the card reads as a wall without it.
|
||||
line.isBlank() -> Spacer(Modifier.height(4.dp))
|
||||
else ->
|
||||
Text(
|
||||
text = tintTags(line, note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = MAX_WRAPPED_LINES,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val hidden = items.size - MAX_CHECKLIST_ROWS
|
||||
if (hidden > 0) {
|
||||
if (lines.size > MAX_PREVIEW_LINES) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.board_more_items, hidden, hidden),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
text = "…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
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
|
||||
private fun LabelChips(labels: List<NoteLabel>) {
|
||||
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.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
labels.take(MAX_LABEL_CHIPS).forEach { label ->
|
||||
val tint = noteTint(label.color)
|
||||
val tint = labelTintFor(label.name, label.color)
|
||||
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,
|
||||
color = tint.chipForeground(dark),
|
||||
color = tint.tagInk(dark),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), RoundedCornerShape(CHIP_RADIUS))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -182,7 +419,91 @@ private fun ReminderChip(
|
||||
}
|
||||
|
||||
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 val CARD_RADIUS = 12.dp
|
||||
private val CARD_ELEVATION = 1.dp
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WHAT A CARD IS: one surface and one edge, neither of which asks the note anything.
|
||||
//
|
||||
// Both are constants HERE rather than columns in NoteTint precisely so the palette
|
||||
// CANNOT vary them; uniformity is the feature. The editor reads the surface from here
|
||||
// too, so a note opened is the same object as the note on the board.
|
||||
|
||||
/**
|
||||
* THE CARD SURFACE — one neutral per theme (M315).
|
||||
*
|
||||
* This used to be a function of the note: a palette fill for a tagged one, a colour
|
||||
* generated from the id for the rest. Both are gone. The operator's verdict after four
|
||||
* passes — "my coloring attempt has failed and nothing looks right… we've tried a lot
|
||||
* to make the color work and somehow it never seems to land" — and the diagnosis under
|
||||
* it is that a card's fill was being asked to carry meaning it could not carry. Nine
|
||||
* keys is too few to identify anything on a board of any size, and a generated fill
|
||||
* identifies nothing by construction, so a coloured board taught the eye to read hue
|
||||
* as significant and then handed it noise. Colour lives on the TAG now, where the
|
||||
* thing it names is right beside it.
|
||||
*
|
||||
* The values are `neutral-900` on dark and white on light — exactly what the palette's
|
||||
* `default` always was, and exactly what the web card and both editors already use, so
|
||||
* this is a collapse onto a surface every surface already had rather than a new colour
|
||||
* anybody has to like. Mirrored as `NOTE_CARD_SURFACE` in `frontend/src/notes/colors.ts`
|
||||
* (`bg-white dark:bg-neutral-900`).
|
||||
*
|
||||
* NOT a colour-scheme role: `surface` is the BOARD in this theme (neutral-50 / -950),
|
||||
* and Material's `surfaceContainer` roles are unset here so they would resolve to
|
||||
* baseline M3 greys rather than to the web's neutrals. Two hexes matching the web beats
|
||||
* a role that nearly does.
|
||||
*
|
||||
* Measured, against the operator's "not the same color as their background but close
|
||||
* to it" — the card fill is deliberately the WEAKEST number on the card:
|
||||
*
|
||||
* card vs board light #FFFFFF on #FAFAFA 1.04
|
||||
* dark #171717 on #0A0A0A 1.10
|
||||
* edge vs card light #B8B8B8 on #FFFFFF 1.98
|
||||
* dark #404040 on #171717 1.73
|
||||
* body vs card light #171717 on #FFFFFF 17.93 (needs 4.5)
|
||||
* dark #FAFAFA on #171717 17.17
|
||||
* muted vs card light #404040 on #FFFFFF 10.37
|
||||
* dark #E5E5E5 on #171717 14.23
|
||||
*
|
||||
* A card is not separated from the board by its fill and never was — the edge and the
|
||||
* shadow do that, which is why 1.04 is enough and why it has to stay near 1. A fill
|
||||
* that separated on its own would be a panel, and a board of panels is the wall this
|
||||
* whole line of work started from.
|
||||
*/
|
||||
fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT
|
||||
|
||||
private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF)
|
||||
private val CARD_SURFACE_DARK = Color(0xFF171717)
|
||||
|
||||
// THE CARD'S EDGE — one grey, every card, both themes. Since M315 it is the only thing
|
||||
// that differs from the board by more than a hair, which makes it structure rather than
|
||||
// decoration: it is what a card IS.
|
||||
//
|
||||
// It was already neutral before the fill was. The version that came from the palette
|
||||
// was a `{hue}-900` border and failed twice over: the line measured 1.56-2.09 against
|
||||
// its own fill while the fill managed only 1.03-1.05 against the board, so it was the
|
||||
// loudest thing on the card — and it carried the same information the fill did, so a
|
||||
// field of cards read as a grid of outlines however different the colours inside were.
|
||||
// A neutral line carries no information at all, which is exactly what lets it be
|
||||
// structure instead of content. The fill is that same argument one size up.
|
||||
//
|
||||
// MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #B8B8B8 on white
|
||||
// is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these values already
|
||||
// shipped at across twenty fills (light 1.57-1.98, dark 1.58-1.73), but at the top of
|
||||
// them rather than the ~1.6-1.7 the pair was originally matched on. Softening the light
|
||||
// edge to re-match would weaken the only boundary a white card on a #FAFAFA board has,
|
||||
// and the complaint that started M315 was about fill, never about edge weight. If an
|
||||
// operator pass disagrees it is one constant, in two files.
|
||||
//
|
||||
// NOT a translucent black/white edge, which is the tidier way to write this and was
|
||||
// measured and rejected: a border composites over what is under it, so `White` at 20%
|
||||
// came out #56396D on a purple card and #A3C9C1 on a teal one. With one fill that
|
||||
// argument no longer bites — but an opaque grey is what NoteCard.vue must also write,
|
||||
// and two surfaces stating the same hex is how they stay the same card.
|
||||
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
|
||||
private val CARD_EDGE_DARK = Color(0xFF404040)
|
||||
private val CHIP_RADIUS = 6.dp
|
||||
|
||||
@@ -1,69 +1,89 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
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
|
||||
* thought landed somewhere. Editing is different — a sustained task with the
|
||||
* keyboard up — and a sheet would spend the whole time fighting the IME for the
|
||||
* bottom half of the display. Full screen also gives the actions a bottom bar,
|
||||
* which is where a thumb already is.
|
||||
* Shaped like the capture sheet it replaced — a rounded card that begins below the
|
||||
* status bar — so opening a note still reads as something rising over the board
|
||||
* rather than a place you navigated to. It is full height rather than a real
|
||||
* `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session
|
||||
* negotiating with the IME for the bottom half of the display, and the swipe-down it
|
||||
* buys is a gesture back already does. The shape is what was worth keeping.
|
||||
*
|
||||
* The note's own colour paints the WHOLE screen rather than a card inside it, so
|
||||
* opening a note reads as the same object growing to fill the display.
|
||||
* The card's surface paints the WHOLE sheet rather than a panel inside it, so opening
|
||||
* a note reads as the same object growing to fill the display — the more literally
|
||||
* true since M315, where the board and the editor became the same one neutral.
|
||||
*
|
||||
* No save button, deliberately. Writes are continuous, so a button offering to do
|
||||
* what already happened would be a lie with a tap attached; [EditorFooter] in the
|
||||
* bottom corner says the same thing as a fact instead, beside the Done that
|
||||
* leaves.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NoteEditorScreen(
|
||||
note: Note,
|
||||
sessionKey: Long,
|
||||
labels: List<Label>,
|
||||
saving: Boolean,
|
||||
error: String?,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(note.color)
|
||||
|
||||
// Keyed by note id: the editor is reused across notes, and without the key the
|
||||
// second note opened would show the first one's text.
|
||||
var body by remember(note.id) { mutableStateOf(note.body) }
|
||||
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
|
||||
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
|
||||
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
|
||||
// needs a key — but a draft's id changes the moment it is first saved, and
|
||||
// re-keying on that would reset this state to whatever the store just returned,
|
||||
// throwing away every character typed during the write.
|
||||
//
|
||||
// BLOCKS rather than one string, because a checklist item is drawn as a real
|
||||
// checkbox now and a widget cannot live inside a text field. The note is still one
|
||||
// markdown body underneath — see EditorBlock.kt — and `bodyText` is what is saved.
|
||||
//
|
||||
// Saveable, because a new note has nothing to fall back on if the phone rotates
|
||||
// mid-capture. The saver carries the TEXT and re-derives the shape, since a block's
|
||||
// id means nothing across a process death.
|
||||
var blocks by
|
||||
rememberSaveable(sessionKey, stateSaver = blocksSaver) {
|
||||
mutableStateOf(splitBlocks(note.body).focusedAtEnd())
|
||||
}
|
||||
// Which field the caret is wanted in, or null. Held HERE rather than inside
|
||||
// BlockBody because the toolbar's checklist button also asks for one.
|
||||
var focus by remember(sessionKey) { mutableStateOf<Long?>(null) }
|
||||
|
||||
val bodyText = remember(blocks) { joinBlocks(blocks) }
|
||||
|
||||
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
|
||||
var confirmingDelete by remember(sessionKey) { mutableStateOf(false) }
|
||||
|
||||
// A note in the trash is a record, not a document: editing one would silently
|
||||
// resurrect work that was meant to be thrown away. It renders read-only, with
|
||||
@@ -75,8 +95,8 @@ fun NoteEditorScreen(
|
||||
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
|
||||
// revision identical to the one before it.
|
||||
val flush = {
|
||||
if (!readOnly && body != note.body) {
|
||||
onAction(EditorAction.SaveText(body))
|
||||
if (!readOnly && bodyText != note.body) {
|
||||
onAction(EditorAction.SaveText(bodyText))
|
||||
}
|
||||
}
|
||||
val leave = {
|
||||
@@ -84,6 +104,32 @@ fun NoteEditorScreen(
|
||||
onAction(EditorAction.Close)
|
||||
}
|
||||
|
||||
// Opening an existing note means continuing it. Without this the note arrives
|
||||
// unfocused, and carrying on costs a tap into the last field.
|
||||
//
|
||||
// Not for a trashed note: it renders read-only, and a keyboard over a record you
|
||||
// cannot edit is noise.
|
||||
LaunchedEffect(sessionKey) {
|
||||
if (!readOnly) focus = blocks.lastOrNull()?.id
|
||||
}
|
||||
|
||||
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every
|
||||
// keystroke, so the delay only ever elapses once typing stops.
|
||||
//
|
||||
// Saving this often is affordable because a body write no longer costs a
|
||||
// revision: history snapshots once per editing session rather than once per
|
||||
// save. Before that, writing was expensive enough that this editor hoarded
|
||||
// text until it closed — and an app kill mid-session lost the lot.
|
||||
//
|
||||
// For a note that does not exist yet this is also what CREATES it, which is why
|
||||
// every toolbar button works moments after the first keystroke rather than
|
||||
// needing the note to be saved by hand first.
|
||||
LaunchedEffect(bodyText, sessionKey) {
|
||||
if (readOnly || bodyText == note.body) return@LaunchedEffect
|
||||
delay(AUTOSAVE_IDLE_MS)
|
||||
onAction(EditorAction.SaveText(bodyText))
|
||||
}
|
||||
|
||||
BackHandler(onBack = leave)
|
||||
|
||||
// Leaving the APP is not closing the editor, so the text has to be saved
|
||||
@@ -91,83 +137,113 @@ fun NoteEditorScreen(
|
||||
// is exactly the failure that makes someone stop trusting a notes app.
|
||||
FlushOnStop(flush)
|
||||
|
||||
Scaffold(
|
||||
containerColor = tint.background(dark),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = leave) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.editor_back),
|
||||
// The sheet shape, kept. `windowInsetsPadding` both insets the card below the
|
||||
// status bar AND consumes that inset, so the bar inside adds no second gap of
|
||||
// its own — the strip above the rounded corner is what makes this read as a card
|
||||
// over the board rather than a screen that replaced it.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
|
||||
color = noteCardSurface(dark),
|
||||
// Both content colours are spelled out for the reason the toolbar had to
|
||||
// be: Surface and Scaffold each default theirs to contentColorFor(their
|
||||
// container), which returns Unspecified for anything that is not a
|
||||
// colour-SCHEME ROLE. The card surface is not one, so the default publishes
|
||||
// Unspecified as LocalContentColor and everything inside that does not
|
||||
// set its own colour draws black — which is how the last toolbar became
|
||||
// invisible in dark mode.
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Scaffold(
|
||||
containerColor = noteCardSurface(dark),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
topBar = {
|
||||
EditorTopBar(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
onClose = leave,
|
||||
onStartChecklist = {
|
||||
val (next, id) = blocks.plusTask()
|
||||
blocks = next
|
||||
focus = id
|
||||
},
|
||||
onPicker = { picker = it },
|
||||
onConfirmDelete = { confirmingDelete = true },
|
||||
onAction = onAction,
|
||||
)
|
||||
},
|
||||
// Where the action bar used to be, carrying the two things that
|
||||
// belong within reach of a thumb: whether the note is safe, and the
|
||||
// way out. See [EditorFooter] for why the exit is down here and not
|
||||
// only in the top-left corner.
|
||||
bottomBar = {
|
||||
EditorFooter(
|
||||
updatedAt = note.updatedAt,
|
||||
saving = saving,
|
||||
onClose = leave,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// No imePadding here: EditorFooter carries it, so
|
||||
// Scaffold measures that row at its keyboard-lifted
|
||||
// height and the inset already reaches this Column
|
||||
// through `padding`. Adding it again would inset for the
|
||||
// keyboard twice.
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
// A failed save has to be visible HERE. The board renders the
|
||||
// same banner, but a write that fails while the editor is open
|
||||
// would otherwise report itself only after the user had already
|
||||
// left.
|
||||
error?.let { message ->
|
||||
ErrorBanner(
|
||||
message = message,
|
||||
onDismiss = { onAction(EditorAction.DismissError) },
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = tint.background(dark)),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
EditorBottomBar(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
tint = tint,
|
||||
onPicker = { picker = it },
|
||||
onConfirmDelete = { confirmingDelete = true },
|
||||
onAction = onAction,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
// A one-pixel line, not a spinner: a save slow enough to see is worth
|
||||
// showing, and one that isn't must not make the screen jump.
|
||||
if (saving) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
// A failed save has to be visible HERE. The board renders the same
|
||||
// banner, but a write that fails while the editor is open would
|
||||
// otherwise report itself only after the user had already left.
|
||||
error?.let { message ->
|
||||
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
|
||||
}
|
||||
// A note is its body; its NAME is that body's first line, so there
|
||||
// is nothing separate to type into and nothing rendered bolder than
|
||||
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
|
||||
// how the body is DRAWN — checklist items as boxes rather than as
|
||||
// the markup for boxes.
|
||||
BlockBody(
|
||||
blocks = blocks,
|
||||
readOnly = readOnly,
|
||||
focus = focus,
|
||||
onChange = { blocks = it },
|
||||
onFocus = { focus = it },
|
||||
)
|
||||
|
||||
// One field. A note is its body; its NAME is that body's first line, so
|
||||
// there is nothing separate to type into and nothing to render bolder
|
||||
// than the line beneath it (M13 steps 3 and 4).
|
||||
EditorField(
|
||||
value = body,
|
||||
onValueChange = { body = it },
|
||||
hint = R.string.editor_body_hint,
|
||||
enabled = !readOnly,
|
||||
minLines = MIN_BODY_LINES,
|
||||
)
|
||||
// No checklist section. The items ARE lines of the field above
|
||||
// (M304) — rendering them again down here is what would put every
|
||||
// list on screen twice.
|
||||
|
||||
// Below the body, not instead of it, and only once the note has items —
|
||||
// the toolbar's add-checklist action is what puts the first one there.
|
||||
if (note.items.isNotEmpty()) {
|
||||
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
|
||||
}
|
||||
if (note.labels.isNotEmpty()) {
|
||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||
}
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||
}
|
||||
|
||||
note.remindAt?.let { at ->
|
||||
EditorReminderRow(
|
||||
at = at,
|
||||
recurrence = note.recurrence,
|
||||
readOnly = readOnly,
|
||||
onAction = onAction,
|
||||
)
|
||||
note.remindAt?.let { at ->
|
||||
EditorReminderRow(
|
||||
at = at,
|
||||
recurrence = note.recurrence,
|
||||
readOnly = readOnly,
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,31 +257,18 @@ fun NoteEditorScreen(
|
||||
)
|
||||
|
||||
if (confirmingDelete) {
|
||||
// The only irreversible action in the app earns the only confirmation in
|
||||
// it. Everything else — archive, trash, even unlinking a server — undoes.
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingDelete = false },
|
||||
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
|
||||
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmingDelete = false
|
||||
onAction(EditorAction.DeleteForever)
|
||||
}) {
|
||||
Text(stringResource(R.string.editor_delete_forever_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmingDelete = false }) {
|
||||
Text(stringResource(R.string.editor_cancel))
|
||||
}
|
||||
ConfirmDeleteDialog(
|
||||
onConfirm = {
|
||||
confirmingDelete = false
|
||||
onAction(EditorAction.DeleteForever)
|
||||
},
|
||||
onDismiss = { confirmingDelete = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
|
||||
enum class Picker { NONE, 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. */
|
||||
@Composable
|
||||
@@ -219,15 +282,6 @@ private fun EditorOverlays(
|
||||
val dismiss = { onPicker(Picker.NONE) }
|
||||
when (picker) {
|
||||
Picker.NONE -> Unit
|
||||
Picker.COLOR ->
|
||||
ColorSheet(
|
||||
selected = note.color,
|
||||
onPick = {
|
||||
onAction(EditorAction.SetColor(it))
|
||||
dismiss()
|
||||
},
|
||||
onDismiss = dismiss,
|
||||
)
|
||||
Picker.LABELS ->
|
||||
LabelSheet(
|
||||
note = note,
|
||||
@@ -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
|
||||
* the note's colour, and a filled field would draw a second surface over the first
|
||||
* and turn a note into a form.
|
||||
*
|
||||
* One weight throughout. The first line is the note's name, but it is not a
|
||||
* different KIND of text from the line after it, and typing it should not feel like
|
||||
* filling in a header.
|
||||
* Long enough that a normal sentence is one write, short enough that nothing
|
||||
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
|
||||
* cover the window between the last keystroke and this elapsing.
|
||||
*/
|
||||
@Composable
|
||||
private fun EditorField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
@StringRes hint: Int,
|
||||
enabled: Boolean,
|
||||
minLines: Int = 1,
|
||||
) {
|
||||
PlainTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
hint = hint,
|
||||
enabled = enabled,
|
||||
minLines = minLines,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
private const val AUTOSAVE_IDLE_MS = 1_000L
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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
|
||||
* surface resolves it to its own tints. The web app resolves through Tailwind
|
||||
* classes; this table is those same Tailwind colours as literals, so a note that
|
||||
* is amber on the desktop is the same amber on the phone rather than a near-miss.
|
||||
* Generated from tailwindcss 3.4's palette rather than transcribed by eye.
|
||||
* A colour is stored by the core as a key ("red", "teal", …) and every surface
|
||||
* resolves it to its own tints. The web app resolves through Tailwind classes; this
|
||||
* table is those same Tailwind colours as literals, so a tag that is amber on the
|
||||
* desktop is the same amber on the phone rather than a near-miss. Generated from
|
||||
* tailwindcss 3.4's palette rather than transcribed by eye.
|
||||
*
|
||||
* Dark tints keep the web's ALPHA (`dark:bg-red-950/40`) instead of a
|
||||
* precomputed blend — Compose composites a translucent colour over what's beneath
|
||||
* exactly as CSS does, so the card sits on the background the same way in both.
|
||||
* Dark tints keep the web's ALPHA instead of a precomputed blend — Compose composites
|
||||
* a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
|
||||
* background the same way in both.
|
||||
*
|
||||
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one
|
||||
* at all (M315) — the card is one neutral per theme, held in NoteCard.kt beside the
|
||||
* edge, where the palette cannot reach either of them. What is left here is the chip,
|
||||
* the inline `#tag`, and the panels and banners that borrow a hue to say what they
|
||||
* are.
|
||||
*
|
||||
* `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
|
||||
* acid against the neutral surfaces.
|
||||
@@ -27,19 +33,102 @@ data class NoteTint(
|
||||
val darkBackground: Color,
|
||||
val darkBorder: Color,
|
||||
val lightChipBackground: Color,
|
||||
val lightChipForeground: Color,
|
||||
val darkChipBackground: Color,
|
||||
/**
|
||||
* The REMINDER pill's ink, and nothing else's — see [chipForeground].
|
||||
*
|
||||
* Only two of these ten are ever read (`red` when a reminder has passed, `default`
|
||||
* otherwise). They stay a per-hue column because they are transcribed from the
|
||||
* web's literals rather than derived from anything here.
|
||||
*/
|
||||
val lightChipForeground: Color,
|
||||
val darkChipForeground: Color,
|
||||
/**
|
||||
* THE INK A TAG IS DRAWN IN — inline in the prose AND as a chip's text — see
|
||||
* [tagInk].
|
||||
*
|
||||
* These were two columns until M315, and the split was real while it lasted: a chip
|
||||
* brought its own `-100` fill and could afford `-700`, while inline text sat on
|
||||
* whatever the card was, which included a gray-tagged card at `neutral-200` where
|
||||
* `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5
|
||||
* body text needs. One step deeper cleared every fill at once.
|
||||
*
|
||||
* The twenty card fills that split was solving for are gone, so both jobs take this
|
||||
* one value. The direction is deliberate: since M311 a tag whose text is in the body
|
||||
* is drawn where it was typed and NOT repeated as a chip, so the inline token is the
|
||||
* common case and collapsing onto ITS column leaves what is seen most exactly as it
|
||||
* was. The chip is strictly better for the move — on its own fill it goes from
|
||||
* 4.52-8.23 to 6.37-12.01 in light. Dark needed no decision: the two columns already
|
||||
* held the same value for all ten hues.
|
||||
*/
|
||||
val lightTagInk: Color,
|
||||
val darkTagInk: Color,
|
||||
) {
|
||||
/**
|
||||
* The pale fill of a PANEL, a banner, an update card or a picker swatch — the
|
||||
* places that borrow a hue to say what they are. Not a note's: since M315 a card
|
||||
* has one neutral surface and does not come through this table at all.
|
||||
*/
|
||||
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
|
||||
|
||||
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
|
||||
|
||||
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
|
||||
|
||||
/**
|
||||
* The REMINDER pill's ink. NOT a tag's — a tag takes [tagInk] wherever it is drawn.
|
||||
*
|
||||
* Kept apart from [tagInk] because the reminder pill is not a tag: it borrows the
|
||||
* chip's shape and its `red-100`/`black-5` fills, and its `red-700`/`neutral-600`
|
||||
* text is transcribed from NoteCard.vue's literal classes. The two happened to be
|
||||
* one value; making the tag ink one step deeper (M315) is where they parted, and
|
||||
* moving the reminder with it would have silently broken that mirror instead.
|
||||
*/
|
||||
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
|
||||
|
||||
/**
|
||||
* The colour a `#tag` is drawn in — in the note's own words, or as a chip.
|
||||
*
|
||||
* A tag whose text is in the body is no longer repeated as a chip (the card was
|
||||
* printing every tag twice — once where it was typed, once at the top). It is
|
||||
* tinted in place instead, which is both less furniture and a more honest card:
|
||||
* the thing you see IS the thing you would delete to remove the tag.
|
||||
*/
|
||||
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
|
||||
|
||||
/**
|
||||
* A hairline edge for a chip, in its own ink at low alpha.
|
||||
*
|
||||
* THE EDGE IS THE PILL. Against the one card surface a chip's fill measures
|
||||
* 1.02-1.26 in light and 1.02-1.73 in dark — very nearly nothing, and dark red at
|
||||
* 1.02 is literally invisible. Without this the tag name would read as loose text.
|
||||
* The fill only tints a shape the edge is drawing.
|
||||
*
|
||||
* An edge rather than a heavier fill, because a fill loud enough to hold its own
|
||||
* shape would be the loudest thing on a board whose whole point is now that the tag
|
||||
* is the one coloured thing on it.
|
||||
*/
|
||||
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
|
||||
}
|
||||
|
||||
// How strongly a chip's edge is drawn, as a fraction of its own ink.
|
||||
//
|
||||
// SOLVED FOR, NOT GUESSED — and re-solved once the answer became solvable. 0.60 was
|
||||
// picked against the worst case of the time: a chip on a card of its OWN colour, back
|
||||
// when a note took its first tag's fill. It gave 2.32:1 there, missed the 3:1 of WCAG
|
||||
// 1.4.11, and the comment here reasoned its way out of that on the grounds that a
|
||||
// chip's information is its text.
|
||||
//
|
||||
// M315 removed that worst case. The edge is now the ink at alpha over a KNOWN fill, so
|
||||
// the smallest alpha clearing 3:1 for all ten hues is arithmetic rather than judgment:
|
||||
// 0.60 gives 2.75-3.82 in light and misses for six of the ten, 0.65 gives 3.03-4.36 and
|
||||
// misses for none. Dark runs 4.52-5.76. The old comment named 0.80 as the fallback if
|
||||
// the judgment were ever overruled; it is not needed, and it draws a hard outline where
|
||||
// a hairline does the job.
|
||||
//
|
||||
// Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring.
|
||||
private const val CHIP_EDGE_ALPHA = 0.65f
|
||||
|
||||
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
|
||||
val NOTE_TINTS: Map<String, NoteTint> =
|
||||
mapOf(
|
||||
@@ -54,6 +143,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
lightChipForeground = Color(0xFF525252),
|
||||
darkChipBackground = Color(0x1AFFFFFF),
|
||||
darkChipForeground = Color(0xFFD4D4D4),
|
||||
lightTagInk = Color(0xFF404040),
|
||||
darkTagInk = Color(0xFFD4D4D4),
|
||||
),
|
||||
"red" to
|
||||
NoteTint(
|
||||
@@ -66,6 +157,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
lightChipForeground = Color(0xFFB91C1C),
|
||||
darkChipBackground = Color(0x80450A0A),
|
||||
darkChipForeground = Color(0xFFFCA5A5),
|
||||
lightTagInk = Color(0xFF991B1B),
|
||||
darkTagInk = Color(0xFFFCA5A5),
|
||||
),
|
||||
"orange" to
|
||||
NoteTint(
|
||||
@@ -78,6 +171,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
lightChipForeground = Color(0xFFC2410C),
|
||||
darkChipBackground = Color(0x80431407),
|
||||
darkChipForeground = Color(0xFFFDBA74),
|
||||
lightTagInk = Color(0xFF9A3412),
|
||||
darkTagInk = Color(0xFFFDBA74),
|
||||
),
|
||||
"yellow" to
|
||||
NoteTint(
|
||||
@@ -90,6 +185,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
lightChipForeground = Color(0xFF92400E),
|
||||
darkChipBackground = Color(0x80451A03),
|
||||
darkChipForeground = Color(0xFFFCD34D),
|
||||
lightTagInk = Color(0xFF92400E),
|
||||
darkTagInk = Color(0xFFFCD34D),
|
||||
),
|
||||
"green" to
|
||||
NoteTint(
|
||||
@@ -102,6 +199,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
lightChipForeground = Color(0xFF15803D),
|
||||
darkChipBackground = Color(0x80052E16),
|
||||
darkChipForeground = Color(0xFF86EFAC),
|
||||
lightTagInk = Color(0xFF166534),
|
||||
darkTagInk = Color(0xFF86EFAC),
|
||||
),
|
||||
"teal" to
|
||||
NoteTint(
|
||||
@@ -114,6 +213,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
lightChipForeground = Color(0xFF0F766E),
|
||||
darkChipBackground = Color(0x80042F2E),
|
||||
darkChipForeground = Color(0xFF5EEAD4),
|
||||
lightTagInk = Color(0xFF115E59),
|
||||
darkTagInk = Color(0xFF5EEAD4),
|
||||
),
|
||||
"blue" to
|
||||
NoteTint(
|
||||
@@ -126,6 +227,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
lightChipForeground = Color(0xFF1D4ED8),
|
||||
darkChipBackground = Color(0x80172554),
|
||||
darkChipForeground = Color(0xFF93C5FD),
|
||||
lightTagInk = Color(0xFF1E40AF),
|
||||
darkTagInk = Color(0xFF93C5FD),
|
||||
),
|
||||
"purple" to
|
||||
NoteTint(
|
||||
@@ -138,6 +241,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
lightChipForeground = Color(0xFF7E22CE),
|
||||
darkChipBackground = Color(0x803B0764),
|
||||
darkChipForeground = Color(0xFFD8B4FE),
|
||||
lightTagInk = Color(0xFF6B21A8),
|
||||
darkTagInk = Color(0xFFD8B4FE),
|
||||
),
|
||||
"pink" to
|
||||
NoteTint(
|
||||
@@ -150,6 +255,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
lightChipForeground = Color(0xFFBE185D),
|
||||
darkChipBackground = Color(0x80500724),
|
||||
darkChipForeground = Color(0xFFF9A8D4),
|
||||
lightTagInk = Color(0xFF9D174D),
|
||||
darkTagInk = Color(0xFFF9A8D4),
|
||||
),
|
||||
"gray" to
|
||||
NoteTint(
|
||||
@@ -162,6 +269,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
lightChipForeground = Color(0xFF404040),
|
||||
darkChipBackground = Color(0xFF404040),
|
||||
darkChipForeground = Color(0xFFE5E5E5),
|
||||
lightTagInk = Color(0xFF262626),
|
||||
darkTagInk = Color(0xFFE5E5E5),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -175,3 +284,30 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
|
||||
|
||||
/**
|
||||
* The tint for a LABEL, derived from its name when nobody has picked one.
|
||||
*
|
||||
* Every `#tag` is born colourless, so without this a board of tags is a board of
|
||||
* identical grey chips. See `DerivedTint.kt` for why this derives rather than
|
||||
* persisting a colour when the tag is minted.
|
||||
*/
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun labelTintFor(
|
||||
name: String,
|
||||
color: String,
|
||||
): NoteTint = labelTint(name, color)
|
||||
|
||||
/**
|
||||
* [labelTintFor] with no composable context, for a caller building its value inside
|
||||
* `remember` — where a `@Composable` call is not allowed. The card's inline tag
|
||||
* colours are computed there, once per body rather than once per recomposition.
|
||||
*
|
||||
* One implementation, two entry points: the composable one delegates here rather than
|
||||
* repeating the lookup, so the chip and the inline token cannot resolve differently.
|
||||
*/
|
||||
fun labelTint(
|
||||
name: String,
|
||||
color: String,
|
||||
): NoteTint = NOTE_TINTS[resolvedLabelColor(name, color, NOTE_TINTS.keys)] ?: NOTE_TINTS.getValue("default")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -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. */
|
||||
enum class Tone { NEUTRAL, WARN, ERROR }
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldColors
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -20,12 +21,16 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||
/**
|
||||
* A text field with no box around it.
|
||||
*
|
||||
* Every writing surface in the app — the capture sheet, the editor's title and
|
||||
* body, each checklist row — sits on a surface that already has its own edges and
|
||||
* its own colour. Material's filled field would draw a second, differently
|
||||
* coloured box inside the first, which makes writing a note look like filling in a
|
||||
* form. Stripping the container and the indicator in four places independently is
|
||||
* how they drift apart, so it happens once, here.
|
||||
* The search box, the label picker, the sync-pairing form: fields that sit on a
|
||||
* surface which already has its own edges and its own colour, where Material's filled
|
||||
* field would draw a second, differently coloured box inside the first. Stripping the
|
||||
* container and the indicator at each site independently is how they drift apart, so
|
||||
* it happens once, here.
|
||||
*
|
||||
* The note EDITOR no longer comes through this. It dropped to `BasicTextField`
|
||||
* (see `EditorBlock.kt`) for density: Material's field puts 16dp above and below its
|
||||
* text, which is right for a form and is the whole row height on a checklist. Nothing
|
||||
* about "no box" was lost there — BasicTextField never had one.
|
||||
*
|
||||
* The disabled colours are stripped too: a trashed note is shown through this
|
||||
* field read-only, and Material's disabled treatment would grey out text the user
|
||||
@@ -58,18 +63,26 @@ fun PlainTextField(
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
visualTransformation = visualTransformation,
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
// Full-strength, not Material's 38%-alpha disabled treatment: a
|
||||
// trashed note is rendered read-only through this field and its
|
||||
// text is meant to be READ, not visually retired.
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
),
|
||||
colors = plainFieldColors(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One definition of "no box". Two copies of this is exactly the drift this file
|
||||
* exists to prevent.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun plainFieldColors(): TextFieldColors =
|
||||
TextFieldDefaults.colors(
|
||||
// Full-strength, not Material's 38%-alpha disabled treatment: a trashed
|
||||
// note is rendered read-only through this field and its text is meant to
|
||||
// be READ, not visually retired.
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
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. */
|
||||
fun isPast(raw: String): Boolean =
|
||||
runCatching { OffsetDateTime.parse(raw).toInstant() < Instant.now() }.getOrDefault(false)
|
||||
fun isPast(raw: String): Boolean {
|
||||
val at = epochMillis(raw) ?: return false
|
||||
return at < System.currentTimeMillis()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a timestamp is older than [minutes] ago — or absent entirely.
|
||||
@@ -83,8 +87,8 @@ fun olderThan(
|
||||
raw: String?,
|
||||
minutes: Long,
|
||||
): Boolean {
|
||||
val at = raw?.let { runCatching { OffsetDateTime.parse(it).toInstant() }.getOrNull() }
|
||||
return at == null || at < Instant.now().minusSeconds(minutes * SECONDS_PER_MINUTE)
|
||||
val at = raw?.let { epochMillis(it) }
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -24,10 +24,28 @@ data class UpdateState(
|
||||
val available: ClientUpdate? = null,
|
||||
/** A check completed and found nothing. Distinct from "not checked yet". */
|
||||
val upToDate: Boolean = false,
|
||||
/** The available build has been fetched and is sitting in the cache. */
|
||||
val ready: Boolean = false,
|
||||
val downloading: Boolean = false,
|
||||
val working: Boolean = false,
|
||||
val error: String? = null,
|
||||
/** The banner has been waved away — until the app next comes forward. */
|
||||
val nagDismissed: Boolean = false,
|
||||
) {
|
||||
val busy: Boolean get() = checking || 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)))
|
||||
private set
|
||||
|
||||
/** Ask the linked server what it has. */
|
||||
fun check() {
|
||||
/** When the last check ran, so coming back to the app twice in a minute is one. */
|
||||
private var lastCheckAt = 0L
|
||||
|
||||
/** Ask the linked server what it has. The Check button on the sync screen. */
|
||||
fun check() = runCheck(fetch = false)
|
||||
|
||||
/**
|
||||
* The automatic path: look, fetch, then nag.
|
||||
*
|
||||
* Called when the app comes forward. Until this existed an update was only ever
|
||||
* found by someone opening the sync screen and pressing a button — so the ones
|
||||
* that mattered were the ones nobody went looking for.
|
||||
*
|
||||
* Skipped when a check is already in flight, when a build is already waiting, and
|
||||
* when one ran recently: flicking between two apps is not a request to re-check.
|
||||
*/
|
||||
fun checkInBackground() {
|
||||
val now = System.currentTimeMillis()
|
||||
when {
|
||||
state.busy -> Unit
|
||||
|
||||
// Already fetched and waved away — say so again. "Later" is for that
|
||||
// sitting, not forever, and without this branch a single dismissal would
|
||||
// silence the update permanently. Which is precisely the "lost" this whole
|
||||
// path exists to prevent.
|
||||
state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false)
|
||||
|
||||
// Found one and never fetched it — almost always because the last look
|
||||
// happened on mobile data. Retry the FETCH rather than the check, and
|
||||
// ignore the interval: this is what makes an update found on the train
|
||||
// arrive when the person gets home instead of waiting out six hours.
|
||||
state.available != null ->
|
||||
if (AppUpdate.onWifi(context)) viewModelScope.launch { download() }
|
||||
|
||||
// Flicking between two apps is not a request to re-check.
|
||||
now - lastCheckAt < CHECK_INTERVAL_MS -> Unit
|
||||
|
||||
else -> {
|
||||
lastCheckAt = now
|
||||
runCheck(fetch = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runCheck(fetch: Boolean) {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(checking = true, error = null, upToDate = false)
|
||||
state =
|
||||
try {
|
||||
val found = core.clientUpdate(state.installedVersion)
|
||||
state.copy(checking = false, available = found, upToDate = found == null)
|
||||
state.copy(
|
||||
checking = false,
|
||||
available = found,
|
||||
upToDate = found == null,
|
||||
// A build that is still there is worth mentioning again. The
|
||||
// dismissal was for that sitting, not for this version.
|
||||
nagDismissed = if (found == null) state.nagDismissed else false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Broad by intent, as everywhere the core is called: it reports
|
||||
// every failure as one error type carrying a message written to
|
||||
// be read, and a failed check must not take the screen down.
|
||||
state.copy(checking = false, error = e.message ?: FALLBACK)
|
||||
}
|
||||
// Fetched before anything is said, so the banner is a one-tap install
|
||||
// rather than the start of a wait. Off wifi this simply does not happen
|
||||
// and the app stays quiet — the next foreground on wifi picks it up.
|
||||
if (fetch && state.available != null && AppUpdate.onWifi(context)) {
|
||||
download()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the waiting build into the cache, leaving it for [install]. */
|
||||
private suspend fun download() {
|
||||
state = state.copy(downloading = true, error = null)
|
||||
state =
|
||||
try {
|
||||
core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath)
|
||||
state.copy(downloading = false, ready = true)
|
||||
} catch (e: Exception) {
|
||||
state.copy(downloading = false, error = e.message ?: FALLBACK_DOWNLOAD)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop nagging for this sitting. The next trip to the foreground says it again. */
|
||||
fun dismissNag() {
|
||||
state = state.copy(nagDismissed = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* around as an intermediate state they have to think about.
|
||||
* Still one action from the outside. A downloaded APK is not a state anyone wants
|
||||
* to think about, so whether the fetch already happened in the background is this
|
||||
* class's problem rather than the person's.
|
||||
*/
|
||||
fun downloadAndInstall() {
|
||||
viewModelScope.launch {
|
||||
@@ -83,7 +176,7 @@ class UpdateViewModel(
|
||||
val failure =
|
||||
try {
|
||||
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.
|
||||
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
|
||||
} catch (e: Exception) {
|
||||
@@ -126,3 +219,14 @@ class UpdateViewModel(
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -10,16 +10,17 @@
|
||||
|
||||
<!-- Compose sheet -->
|
||||
<string name="compose_open">New note</string>
|
||||
<string name="compose_body_hint">Take a note…</string>
|
||||
<string name="compose_discard">Discard</string>
|
||||
<string name="compose_save">Save</string>
|
||||
|
||||
<!-- Board -->
|
||||
<string name="board_empty_note">Empty note</string>
|
||||
<plurals name="board_more_items">
|
||||
<item quantity="one">+%d more item</item>
|
||||
<item quantity="other">+%d more items</item>
|
||||
</plurals>
|
||||
|
||||
<!-- The long-press menu. Its ITEMS are the editor_* strings, deliberately: a
|
||||
note has one vocabulary of things you can do to it, and a board that said
|
||||
"Delete" where the editor says "Move to trash" would be describing two
|
||||
different apps. Only the wrapper and the undo need words of their own. -->
|
||||
<string name="board_note_actions">Note actions</string>
|
||||
<string name="board_trashed">Moved to trash</string>
|
||||
<string name="board_undo">Undo</string>
|
||||
|
||||
<!-- Empty states. Each destination says something true of ITSELF; a single
|
||||
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
|
||||
@@ -38,12 +39,17 @@
|
||||
<string name="board_open_note">Open note</string>
|
||||
<string name="editor_back">Back to notes</string>
|
||||
<string name="editor_add_checklist">Add a checklist</string>
|
||||
<string name="editor_body_hint">Note</string>
|
||||
<string name="editor_body_hint">Take a note…</string>
|
||||
<string name="editor_add_item">Add item</string>
|
||||
<string name="editor_remove_item">Remove item</string>
|
||||
<string name="editor_remove_label">Remove label</string>
|
||||
<string name="editor_reminder">Set a reminder</string>
|
||||
<string name="editor_more">More actions</string>
|
||||
<string name="editor_saving">Saving…</string>
|
||||
<string name="editor_unsaved">Not saved yet</string>
|
||||
<string name="editor_edited">Edited %1$s</string>
|
||||
<string name="editor_just_now">just now</string>
|
||||
<string name="editor_done">Done</string>
|
||||
<string name="editor_pin">Pin</string>
|
||||
<string name="editor_unpin">Unpin</string>
|
||||
<string name="editor_labels">Labels…</string>
|
||||
@@ -61,7 +67,6 @@
|
||||
<string name="editor_delete_forever_confirm">Delete</string>
|
||||
|
||||
<!-- Pickers -->
|
||||
<string name="color_picker_title">Color</string>
|
||||
<string name="label_picker_title">Labels</string>
|
||||
<string name="label_new_hint">Type a label and press enter</string>
|
||||
<string name="label_from_tag">from #tag</string>
|
||||
@@ -122,6 +127,8 @@
|
||||
<string name="update_current">You\'re on the newest build this server has.</string>
|
||||
<string name="update_check">Check for an update</string>
|
||||
<string name="update_install">Update</string>
|
||||
<string name="update_banner_ready">Build %1$s is downloaded and ready.</string>
|
||||
<string name="update_later">Later</string>
|
||||
<string name="update_failed_title">The update didn\'t install</string>
|
||||
<string name="update_permission_title">Android needs your permission</string>
|
||||
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
|
||||
|
||||
@@ -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
@@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore;
|
||||
use thoughtsync_core::sync::{client, compat, engine, push, state};
|
||||
|
||||
use models::{
|
||||
patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult,
|
||||
RevokeOutcome, SyncOutcome, SyncStatus,
|
||||
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit,
|
||||
NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
|
||||
};
|
||||
|
||||
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]`
|
||||
/// block names, so these stay Rust-side.
|
||||
impl ThoughtSync {
|
||||
@@ -571,7 +617,6 @@ mod tests {
|
||||
fn draft(body: &str) -> NoteDraft {
|
||||
NoteDraft {
|
||||
body: body.to_string(),
|
||||
color: "default".to_string(),
|
||||
items: None,
|
||||
}
|
||||
}
|
||||
@@ -625,7 +670,6 @@ mod tests {
|
||||
let created = app
|
||||
.create_note(NoteDraft {
|
||||
body: String::new(),
|
||||
color: "default".to_string(),
|
||||
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
|
||||
})
|
||||
.expect("create should succeed");
|
||||
@@ -661,7 +705,6 @@ mod tests {
|
||||
let note = app
|
||||
.create_note(NoteDraft {
|
||||
body: "Packing".to_string(),
|
||||
color: "default".to_string(),
|
||||
items: Some(vec!["socks".to_string()]),
|
||||
})
|
||||
.expect("create");
|
||||
@@ -682,8 +725,9 @@ mod tests {
|
||||
assert!(ticked.items[1].checked);
|
||||
assert_eq!(
|
||||
ticked.items[1].text, "charger",
|
||||
"ticking a box must not disturb its text — the two setters write \
|
||||
different columns and neither may clear the other"
|
||||
"ticking a box must not disturb its text — both setters rewrite the \
|
||||
same line of the body now, so one clobbering the other is a live risk \
|
||||
rather than a theoretical one"
|
||||
);
|
||||
|
||||
let renamed = app
|
||||
|
||||
+59
-12
@@ -33,7 +33,6 @@ pub struct Note {
|
||||
/// Always present. Derived by the core, never stored.
|
||||
pub display_title: String,
|
||||
pub body: String,
|
||||
pub color: String,
|
||||
pub position: i64,
|
||||
pub pinned: bool,
|
||||
pub archived: bool,
|
||||
@@ -49,6 +48,63 @@ pub struct Note {
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// A checklist item as it sits in a note's body.
|
||||
///
|
||||
/// Mirrors `derive::DerivedItem`. Carries the LINE because every renderer that walks
|
||||
/// a body line by line needs the text, the state and the position together — the card
|
||||
/// to draw a box in the right place, the block editor to know where one block ends.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct BodyItem {
|
||||
pub line: u32,
|
||||
pub text: String,
|
||||
pub checked: bool,
|
||||
}
|
||||
|
||||
impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
|
||||
fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self {
|
||||
let thoughtsync_core::local::derive::DerivedItem {
|
||||
text,
|
||||
checked,
|
||||
line,
|
||||
} = i;
|
||||
BodyItem {
|
||||
line,
|
||||
text,
|
||||
checked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `#tag` and where it sits in a note's body.
|
||||
///
|
||||
/// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather
|
||||
/// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16
|
||||
/// code units precisely because Kotlin's `AnnotatedString` counts that way.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct BodyTag {
|
||||
pub line: u32,
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl From<thoughtsync_core::local::derive::DerivedTag> for BodyTag {
|
||||
fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self {
|
||||
let thoughtsync_core::local::derive::DerivedTag {
|
||||
line,
|
||||
start,
|
||||
end,
|
||||
name,
|
||||
} = t;
|
||||
BodyTag {
|
||||
line,
|
||||
start,
|
||||
end,
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An Android build the linked server is offering, already judged to be newer.
|
||||
///
|
||||
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
|
||||
@@ -130,7 +186,6 @@ impl From<core_models::Note> for Note {
|
||||
id,
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
position,
|
||||
pinned,
|
||||
archived,
|
||||
@@ -149,7 +204,6 @@ impl From<core_models::Note> for Note {
|
||||
id,
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
position,
|
||||
pinned,
|
||||
archived,
|
||||
@@ -287,7 +341,6 @@ pub struct NoteQuery {
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteFacets {
|
||||
pub q: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub label: Option<Vec<String>>,
|
||||
pub has_reminder: Option<bool>,
|
||||
pub has_attachment: Option<bool>,
|
||||
@@ -316,7 +369,6 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
fn from(value: NoteFacets) -> Self {
|
||||
let NoteFacets {
|
||||
q,
|
||||
color,
|
||||
label,
|
||||
has_reminder,
|
||||
has_attachment,
|
||||
@@ -325,7 +377,6 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
} = value;
|
||||
core_models::Facets {
|
||||
q,
|
||||
color,
|
||||
label,
|
||||
has_reminder,
|
||||
has_attachment,
|
||||
@@ -339,8 +390,6 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteDraft {
|
||||
pub body: String,
|
||||
/// "default" unless the user picked a colour.
|
||||
pub color: String,
|
||||
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
|
||||
/// is not an alternative to `body` — it is an addition to it.
|
||||
pub items: Option<Vec<String>>,
|
||||
@@ -348,8 +397,8 @@ pub struct NoteDraft {
|
||||
|
||||
impl From<NoteDraft> for core_models::NoteCreateInput {
|
||||
fn from(value: NoteDraft) -> Self {
|
||||
let NoteDraft { body, color, items } = value;
|
||||
core_models::NoteCreateInput { body, color, items }
|
||||
let NoteDraft { body, items } = value;
|
||||
core_models::NoteCreateInput { body, items }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +413,6 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
|
||||
#[derive(Debug, Clone, uniffi::Enum)]
|
||||
pub enum NoteEdit {
|
||||
Body { value: String },
|
||||
Color { value: String },
|
||||
Pinned { value: bool },
|
||||
Archived { value: bool },
|
||||
RemindAt { value: String },
|
||||
@@ -384,7 +432,6 @@ impl NoteEdit {
|
||||
use serde_json::Value;
|
||||
match self {
|
||||
NoteEdit::Body { value } => ("body", Value::String(value)),
|
||||
NoteEdit::Color { value } => ("color", Value::String(value)),
|
||||
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
|
||||
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
|
||||
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
|
||||
|
||||
+707
-9
@@ -1,19 +1,31 @@
|
||||
//! Deriving `#tags` from a note's body — the local mirror of what the server computes
|
||||
//! on save. Pure string scanning (no regex dependency), kept in lockstep with the
|
||||
//! frontend's inline rules (see frontend notes/markdown.ts):
|
||||
//! Deriving structure from a note's body — the local mirror of what the server
|
||||
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep
|
||||
//! with the frontend's inline rules (see frontend notes/markdown.ts):
|
||||
//!
|
||||
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
|
||||
//! On save these become labels attached with `via_tag = true`.
|
||||
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
|
||||
//! table of items beside it, so a list can sit between two paragraphs instead of
|
||||
//! only after them.
|
||||
//!
|
||||
//! The two are the same idea at different strengths. Tags MATERIALISE into label
|
||||
//! rows, because the board queries by label. Items materialise into nothing,
|
||||
//! because nothing queries them: their only readers are the card, the editor and
|
||||
//! `display_title`. So `extract_items` is the whole storage layer for a checklist,
|
||||
//! and the rewriters below are how one is edited.
|
||||
//!
|
||||
//! Dedupes case-insensitively, preserving first-seen order.
|
||||
//!
|
||||
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
|
||||
//! capture-and-recall surface, and a linking system is organization.
|
||||
|
||||
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
||||
pub fn extract_tags(body: &str) -> Vec<String> {
|
||||
let chars: Vec<char> = body.chars().collect();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
/// Every `#tag` in ONE line, as `(start, end, name)` in char indices.
|
||||
///
|
||||
/// Char indices rather than byte offsets so the spans can be used to cut the tags
|
||||
/// back out of the line without ever landing mid-codepoint — see
|
||||
/// [`lift_standalone_tags`], which is the only reason the spans exist.
|
||||
fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
|
||||
let mut out: Vec<(usize, usize, String)> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '#' {
|
||||
@@ -24,8 +36,7 @@ pub fn extract_tags(body: &str) -> Vec<String> {
|
||||
while j < chars.len() && is_tag_char(chars[j]) {
|
||||
j += 1;
|
||||
}
|
||||
let tag: String = chars[i + 1..j].iter().collect();
|
||||
push_unique(&mut out, &tag);
|
||||
out.push((i, j, chars[i + 1..j].iter().collect()));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
@@ -35,6 +46,183 @@ pub fn extract_tags(body: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
||||
///
|
||||
/// Line by line, which changes nothing: a line start and a `\n` are both boundaries,
|
||||
/// so the same tags come out. It means there is ONE scanner rather than two — this and
|
||||
/// [`lift_standalone_tags`] cannot disagree about what a tag is.
|
||||
pub fn extract_tags(body: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for line in body.split('\n') {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
for (_, _, name) in line_tags(&chars) {
|
||||
push_unique(&mut out, &name);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
|
||||
///
|
||||
/// The card no longer prints a chip for a tag whose text is still in the note — it
|
||||
/// colours the token where it was typed instead. To do that a renderer needs the
|
||||
/// SPAN, not just the name, and asking it to find the name again would be a second
|
||||
/// grammar quietly disagreeing with this one about what `##a` or `#1` is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DerivedTag {
|
||||
/// Which body line it sits on, like [`DerivedItem::line`].
|
||||
pub line: u32,
|
||||
/// Offsets into that line, in UTF-16 code units — INCLUDING the leading `#`.
|
||||
///
|
||||
/// UTF-16 rather than chars or bytes because the two languages that consume this
|
||||
/// both index strings that way: Kotlin's `AnnotatedString` and JavaScript. A char
|
||||
/// index is right up until somebody puts an emoji before a tag, and then it lands
|
||||
/// mid-token with no error anywhere.
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Every `#tag` in `body` with its position — the same scan [`extract_tags`] does,
|
||||
/// keeping the spans instead of throwing them away.
|
||||
///
|
||||
/// Not deduped, unlike `extract_tags`: two mentions of `#todo` are two pieces of text
|
||||
/// to colour. Fences are not skipped either, and that is deliberate — `extract_tags`
|
||||
/// does not skip them, so a `#tag` inside a code block IS a label on the note, and a
|
||||
/// renderer that left it plain would be the only surface disagreeing.
|
||||
pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> {
|
||||
let mut out = Vec::new();
|
||||
for (n, line) in body.split('\n').enumerate() {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let spans = line_tags(&chars);
|
||||
if spans.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Prefix sums, built once per tagged line: char index -> UTF-16 offset.
|
||||
let mut units: Vec<u32> = Vec::with_capacity(chars.len() + 1);
|
||||
let mut total: u32 = 0;
|
||||
units.push(0);
|
||||
for c in &chars {
|
||||
total += c.len_utf16() as u32;
|
||||
units.push(total);
|
||||
}
|
||||
for (start, end, name) in spans {
|
||||
out.push(DerivedTag {
|
||||
line: n as u32,
|
||||
start: units[start],
|
||||
end: units[end],
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a line opens or closes a fenced code block.
|
||||
fn is_fence(line: &str) -> bool {
|
||||
let trimmed = line.trim_start();
|
||||
trimmed.starts_with("```") || trimmed.starts_with("~~~")
|
||||
}
|
||||
|
||||
/// Runs of three or more newlines become two, and the ends are trimmed.
|
||||
///
|
||||
/// Removing a line must not leave a hole where it was.
|
||||
fn collapse_blank_runs(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut run = 0;
|
||||
for c in text.chars() {
|
||||
if c == '\n' {
|
||||
run += 1;
|
||||
if run <= 2 {
|
||||
out.push(c);
|
||||
}
|
||||
} else {
|
||||
run = 0;
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out.trim_matches('\n').to_string()
|
||||
}
|
||||
|
||||
/// Split a body's tags by whether the text around them can be taken away.
|
||||
///
|
||||
/// Returns `(standalone, inline, lifted_body)`.
|
||||
///
|
||||
/// THE RULE: a line containing nothing but tags and whitespace is removed. Anything
|
||||
/// else is left exactly as written.
|
||||
///
|
||||
/// The MIRROR of `split_body_tags` in the server's `notes/tags.py`, and it has to stay
|
||||
/// one: a note lifted differently here than there would change under the operator the
|
||||
/// moment it synced. Same discipline, and the same reason, as `DerivedTint`.
|
||||
///
|
||||
/// The conservative reading of "standalone" is deliberate. A trailing tag is
|
||||
/// ambiguous and the text does not say which it is — `buy milk #grocery` is filing,
|
||||
/// `remember to call #mom` is the sentence's object, and lifting the second leaves
|
||||
/// "remember to call". A tag sharing a line with words keeps its words.
|
||||
///
|
||||
/// `standalone` tags become ORDINARY labels (`via_tag = 0`): nothing is left to derive
|
||||
/// them from, so the row becomes the record and the chip's × becomes the way to remove
|
||||
/// one. `inline` tags stay derived exactly as before. That is what `via_tag` means from
|
||||
/// here on — backed by text still in the body.
|
||||
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
|
||||
let mut standalone: Vec<String> = Vec::new();
|
||||
let mut inline: Vec<String> = Vec::new();
|
||||
let mut kept: Vec<&str> = Vec::new();
|
||||
let mut in_fence = false;
|
||||
|
||||
for line in body.split('\n') {
|
||||
if is_fence(line) {
|
||||
in_fence = !in_fence;
|
||||
kept.push(line);
|
||||
continue;
|
||||
}
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let spans = line_tags(&chars);
|
||||
// Cut the tags out and see whether anything is left. That is what
|
||||
// "standalone" means, and it is the whole rule.
|
||||
let mut remainder = String::new();
|
||||
let mut pos = 0;
|
||||
for (start, end, _) in &spans {
|
||||
remainder.extend(chars[pos..*start].iter());
|
||||
pos = *end;
|
||||
}
|
||||
remainder.extend(chars[pos..].iter());
|
||||
|
||||
// A fence's contents are CODE: a `#tag` there is a shell comment in somebody's
|
||||
// snippet, and deleting the line would eat part of their example.
|
||||
if in_fence || spans.is_empty() || !remainder.trim().is_empty() {
|
||||
for (_, _, name) in &spans {
|
||||
push_unique(&mut inline, name);
|
||||
}
|
||||
kept.push(line);
|
||||
} else {
|
||||
for (_, _, name) in &spans {
|
||||
push_unique(&mut standalone, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lifted = collapse_blank_runs(&kept.join("\n"));
|
||||
if !body.trim().is_empty() && lifted.trim().is_empty() {
|
||||
// The note was NOTHING but tags. Lifting would leave a blank card, which is a
|
||||
// worse outcome than a duplicated chip — so leave it alone.
|
||||
let mut all = standalone;
|
||||
for name in &inline {
|
||||
push_unique(&mut all, name);
|
||||
}
|
||||
return (Vec::new(), all, body.to_string());
|
||||
}
|
||||
|
||||
// A tag that ALSO appears in prose stays derived: the prose copy still backs it,
|
||||
// so deleting that copy should still detach the label.
|
||||
let inline_lower: Vec<String> = inline.iter().map(|n| n.to_lowercase()).collect();
|
||||
let standalone = standalone
|
||||
.into_iter()
|
||||
.filter(|n| !inline_lower.contains(&n.to_lowercase()))
|
||||
.collect();
|
||||
(standalone, inline, lifted)
|
||||
}
|
||||
|
||||
fn is_tag_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_' || c == '-'
|
||||
}
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -72,4 +493,281 @@ mod tests {
|
||||
fn empty_body() {
|
||||
assert!(extract_tags("").is_empty());
|
||||
}
|
||||
|
||||
// ── tag spans, for the renderer that draws them in place ─────────────────
|
||||
|
||||
#[test]
|
||||
fn tag_spans_carry_the_hash_and_the_line() {
|
||||
let spans = extract_tag_spans("buy milk #grocery\nand call #mom about #mom");
|
||||
assert_eq!(spans.len(), 3);
|
||||
assert_eq!((spans[0].line, spans[0].start, spans[0].end), (0, 9, 17));
|
||||
assert_eq!(spans[0].name, "grocery");
|
||||
// Not deduped: two mentions are two pieces of text to colour.
|
||||
assert_eq!(spans[1].line, 1);
|
||||
assert_eq!(spans[2].name, "mom");
|
||||
assert_eq!((spans[2].start, spans[2].end), (20, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_spans_are_utf16_offsets_not_char_indices() {
|
||||
// The emoji is ONE char and TWO UTF-16 code units. Kotlin and JS both index
|
||||
// the second way, so a char index would highlight one character too early.
|
||||
let spans = extract_tag_spans("🎁 #gift");
|
||||
assert_eq!(spans.len(), 1);
|
||||
assert_eq!((spans[0].start, spans[0].end), (3, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_spans_agree_with_extract_tags_about_what_a_tag_is() {
|
||||
let body = "#1 nope a#b no but #Yes ##no";
|
||||
let names: Vec<String> = extract_tag_spans(body)
|
||||
.into_iter()
|
||||
.map(|t| t.name)
|
||||
.collect();
|
||||
assert_eq!(names, extract_tags(body));
|
||||
}
|
||||
|
||||
// ── lifting standalone tags ──────────────────────────────────────────────
|
||||
//
|
||||
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
|
||||
// A note lifted differently here than there would change under the operator the
|
||||
// moment it synced, so these are the cases that file agrees to.
|
||||
|
||||
#[test]
|
||||
fn lifts_a_line_that_is_nothing_but_tags() {
|
||||
let (standalone, inline, body) = lift_standalone_tags("#todo\nreorganize the homepage");
|
||||
assert_eq!(standalone, vec!["todo"]);
|
||||
assert!(inline.is_empty());
|
||||
assert_eq!(body, "reorganize the homepage");
|
||||
|
||||
let (standalone, _, body) = lift_standalone_tags("needs a tauri app\n#todo");
|
||||
assert_eq!(standalone, vec!["todo"]);
|
||||
assert_eq!(body, "needs a tauri app");
|
||||
|
||||
let (standalone, _, body) = lift_standalone_tags("#todo #work\nreal text");
|
||||
assert_eq!(standalone, vec!["todo", "work"]);
|
||||
assert_eq!(body, "real text");
|
||||
}
|
||||
|
||||
/// The cases that must come back byte-identical. Getting any of these wrong
|
||||
/// destroys somebody's words, which is why the rule is the conservative one:
|
||||
/// a trailing tag is ambiguous and the text does not say which kind it is.
|
||||
#[test]
|
||||
fn leaves_a_tag_that_shares_its_line_with_words() {
|
||||
for prose in [
|
||||
"remember to call #mom tomorrow",
|
||||
"buy milk #grocery",
|
||||
"#2024\nreal",
|
||||
] {
|
||||
let (standalone, _, body) = lift_standalone_tags(prose);
|
||||
assert!(standalone.is_empty(), "{prose}");
|
||||
assert_eq!(body, prose, "{prose}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_line_leaves_no_hole() {
|
||||
let (_, _, body) = lift_standalone_tags("foo\n\n#todo\n\nbar");
|
||||
assert_eq!(body, "foo\n\nbar");
|
||||
}
|
||||
|
||||
/// A `#tag` in a fence is a shell comment in somebody's snippet. It still becomes
|
||||
/// a label — it always has — but the line is never touched.
|
||||
#[test]
|
||||
fn never_touches_a_fenced_line() {
|
||||
let fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone";
|
||||
let (standalone, inline, body) = lift_standalone_tags(fenced);
|
||||
assert!(standalone.is_empty());
|
||||
assert_eq!(inline, vec!["deploy"]);
|
||||
assert_eq!(body, fenced);
|
||||
}
|
||||
|
||||
/// Lifting would leave a blank card, which is worse than the duplication this
|
||||
/// removes. So the note keeps its text and its tags stay derived.
|
||||
#[test]
|
||||
fn will_not_blank_a_note_that_is_only_tags() {
|
||||
let (standalone, inline, body) = lift_standalone_tags("#todo");
|
||||
assert!(standalone.is_empty());
|
||||
assert_eq!(inline, vec!["todo"]);
|
||||
assert_eq!(body, "#todo");
|
||||
}
|
||||
|
||||
/// Appearing on its own line does NOT lift a tag also written in a sentence — the
|
||||
/// sentence still backs it, so deleting the sentence should still detach it.
|
||||
#[test]
|
||||
fn a_tag_still_in_prose_stays_derived() {
|
||||
let (standalone, inline, body) = lift_standalone_tags("#todo\nremember the #todo list");
|
||||
assert!(standalone.is_empty());
|
||||
assert_eq!(inline, vec!["todo"]);
|
||||
assert_eq!(body, "remember the #todo list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifting_an_empty_body_is_a_no_op() {
|
||||
let (standalone, inline, body) = lift_standalone_tags("");
|
||||
assert!(standalone.is_empty());
|
||||
assert!(inline.is_empty());
|
||||
assert_eq!(body, "");
|
||||
}
|
||||
|
||||
// ── checklist items ─────────────────────────────────────────────────────
|
||||
|
||||
fn item(text: &str, checked: bool, line: u32) -> DerivedItem {
|
||||
DerivedItem {
|
||||
text: text.to_string(),
|
||||
checked,
|
||||
line,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_basic() {
|
||||
let body = "shopping\n\n- [ ] milk\n- [x] eggs";
|
||||
assert_eq!(
|
||||
extract_items(body),
|
||||
vec![item("milk", false, 2), item("eggs", true, 3)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_may_sit_between_paragraphs() {
|
||||
// The whole reason the body owns the list: a table of rows could only ever
|
||||
// render after the prose.
|
||||
let body = "before\n- [ ] middle\nafter";
|
||||
assert_eq!(extract_items(body), vec![item("middle", false, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_reject_near_misses() {
|
||||
// Each of these is prose, and each has been someone's bug report somewhere.
|
||||
for body in [
|
||||
"-[ ] no space after the dash",
|
||||
"- [] empty brackets",
|
||||
"- [ ]no space after the brackets",
|
||||
"- [y] not a mark",
|
||||
"a [ ] mid sentence",
|
||||
"[ ] no bullet at all",
|
||||
] {
|
||||
assert!(extract_items(body).is_empty(), "should be prose: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_accept_star_bullets_and_indentation() {
|
||||
// `*` because markdown.ts already takes it for a plain bullet.
|
||||
let body = "* [ ] star\n - [x] indented";
|
||||
assert_eq!(
|
||||
extract_items(body),
|
||||
vec![item("star", false, 0), item("indented", true, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_item_is_still_an_item() {
|
||||
// What pressing Enter on a list leaves behind.
|
||||
assert_eq!(extract_items("- [ ]"), vec![item("", false, 0)]);
|
||||
assert_eq!(extract_items("- [ ] "), vec![item("", false, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_x_parses_and_normalises_on_rewrite() {
|
||||
assert_eq!(extract_items("- [X] done"), vec![item("done", true, 0)]);
|
||||
// Touching it once canonicalises it, and never again.
|
||||
assert_eq!(set_item_checked("- [X] done", 0, true), "- [x] done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checking_preserves_indent_bullet_and_text() {
|
||||
assert_eq!(set_item_checked(" * [ ] milk", 0, true), " * [x] milk");
|
||||
assert_eq!(set_item_checked("- [x] milk", 0, false), "- [ ] milk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checking_addresses_items_not_lines() {
|
||||
let body = "note\n- [ ] a\nprose\n- [ ] b";
|
||||
assert_eq!(
|
||||
set_item_checked(body, 1, true),
|
||||
"note\n- [ ] a\nprose\n- [x] b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_text_keeps_state() {
|
||||
assert_eq!(set_item_text("- [x] old", 0, "new"), "- [x] new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_takes_the_whole_line() {
|
||||
let body = "keep\n- [ ] drop\n- [ ] stay";
|
||||
assert_eq!(remove_item(body, 0), "keep\n- [ ] stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_spaces_like_the_exporter() {
|
||||
// Prose then a blank line then the list — byte-for-byte what
|
||||
// import_export.py:_note_markdown writes, which is what the server
|
||||
// migration will fold existing rows into.
|
||||
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
|
||||
// Nothing between consecutive items.
|
||||
let one = "a note\n\n- [ ] milk";
|
||||
assert_eq!(
|
||||
append_item(one, "eggs", false),
|
||||
format!("{one}\n- [ ] eggs")
|
||||
);
|
||||
// A list-only note starts at the first line.
|
||||
assert_eq!(append_item("", "milk", false), "- [ ] milk");
|
||||
assert_eq!(append_item("\n\n", "milk", false), "- [ ] milk");
|
||||
// Carries state, which is what the two migrations need of it.
|
||||
assert_eq!(append_item("", "done", true), "- [x] done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_marker_names_a_list_only_note() {
|
||||
assert_eq!(strip_marker("- [x] milk"), "milk");
|
||||
assert_eq!(strip_marker("just prose"), "just prose");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_item_is_what_extract_reads_back() {
|
||||
assert_eq!(render_item("milk", false), "- [ ] milk");
|
||||
assert_eq!(render_item("done", true), "- [x] done");
|
||||
// An empty item has no trailing space, so a round trip does not grow it.
|
||||
assert_eq!(render_item("", false), "- [ ]");
|
||||
let line = render_item("milk", true);
|
||||
assert_eq!(extract_items(&line), vec![item("milk", true, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_carry_the_line_they_sit_on() {
|
||||
let found = extract_items("a\n- [ ] x\nb\n- [x] y");
|
||||
assert_eq!(found.iter().map(|i| i.line).collect::<Vec<_>>(), vec![1, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_index_does_nothing() {
|
||||
// The index comes from a UI that may be a moment behind the store. A tap
|
||||
// that arrives late should be inert, not fatal.
|
||||
let body = "- [ ] only";
|
||||
assert_eq!(set_item_checked(body, 7, true), body);
|
||||
assert_eq!(remove_item(body, 7), body);
|
||||
assert_eq!(set_item_text(body, 7, "x"), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_body_is_returned_byte_identical() {
|
||||
let body = "just prose\nwith two lines";
|
||||
assert_eq!(set_item_checked(body, 0, true), body);
|
||||
assert_eq!(set_item_text(body, 0, "x"), body);
|
||||
assert_eq!(remove_item(body, 0), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_is_stable() {
|
||||
let body = "- [ ] a\n- [x] b\n- [ ] c";
|
||||
let items = extract_items(body);
|
||||
// Ticking and unticking returns the original bytes.
|
||||
let touched = set_item_checked(&set_item_checked(body, 0, true), 0, false);
|
||||
assert_eq!(touched, body);
|
||||
assert_eq!(extract_items(&touched), items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ pub struct Note {
|
||||
/// never stored.
|
||||
pub display_title: String,
|
||||
pub body: String,
|
||||
pub color: String,
|
||||
pub position: i64,
|
||||
pub pinned: bool,
|
||||
pub archived: bool,
|
||||
@@ -121,16 +120,10 @@ pub struct User {
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
fn default_color() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NoteCreateInput {
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
pub items: Option<Vec<String>>,
|
||||
}
|
||||
@@ -154,8 +147,6 @@ pub struct Facets {
|
||||
#[serde(default)]
|
||||
pub q: Option<String>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub label: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub has_reminder: Option<bool>,
|
||||
|
||||
+285
-2
@@ -6,14 +6,16 @@
|
||||
//!
|
||||
//! 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#"
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
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
|
||||
position 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
|
||||
);
|
||||
CREATE INDEX idx_items_note ON checklist_items (note_id);
|
||||
-- Both dropped in v8; kept here so an existing database has something to migrate
|
||||
-- FROM, exactly as `kind` above is kept for v6.
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -180,7 +184,96 @@ ALTER TABLE notes DROP COLUMN title;
|
||||
ALTER TABLE note_revisions DROP COLUMN title;
|
||||
"#;
|
||||
|
||||
// v8 (M304): `checklist_items` is gone. The body IS the checklist — a `- [ ] milk`
|
||||
// line is the item — so a list can sit between two paragraphs instead of only after
|
||||
// them, which a side table could never express no matter how it was styled.
|
||||
//
|
||||
// Rust rather than a SQL const, for two reasons. The fold has to produce EXACTLY what
|
||||
// `derive::append_item` produces, and expressing that in SQL would be a second
|
||||
// implementation of the layout rule. And `group_concat` only gained a guaranteed
|
||||
// ORDER BY in SQLite 3.44 — a checklist that silently reordered itself during the
|
||||
// migration would be a poor way to find that out.
|
||||
//
|
||||
// `updated_at` and `dirty` are deliberately NOT touched. The server's Alembic
|
||||
// migration folds the same rows with the same spacing, so both sides land on
|
||||
// identical bodies and this needs no sync at all; marking every note dirty would
|
||||
// push a body the server already has, and would do it for every device at once.
|
||||
fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> {
|
||||
// Grouped in one pass — the query is ordered by note, so a change of note_id is
|
||||
// the group boundary. `rowid` breaks ties, because `position` was only ever
|
||||
// advisory and two rows sharing one is not a reason to reorder someone's list.
|
||||
let mut grouped: Vec<(String, Vec<(String, bool)>)> = Vec::new();
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT note_id, text, checked FROM checklist_items
|
||||
ORDER BY note_id ASC, position ASC, rowid ASC",
|
||||
)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let note_id: String = row.get(0)?;
|
||||
let text: String = row.get(1)?;
|
||||
let checked: bool = row.get(2)?;
|
||||
match grouped.last_mut() {
|
||||
Some((id, items)) if *id == note_id => items.push((text, checked)),
|
||||
_ => grouped.push((note_id, vec![(text, checked)])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (note_id, items) in grouped {
|
||||
let existing: Option<String> = conn
|
||||
.query_row("SELECT body FROM notes WHERE id = ?1", [¬e_id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
// An item whose note is already gone has nothing to fold into. The foreign key
|
||||
// should make this impossible; skipping costs nothing, and failing here would
|
||||
// leave the only copy of someone's notes half-migrated.
|
||||
let mut body = match existing {
|
||||
Some(b) => b,
|
||||
None => continue,
|
||||
};
|
||||
for (text, checked) in items {
|
||||
body = derive::append_item(&body, &text, checked);
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||
params![body, note_id],
|
||||
)?;
|
||||
}
|
||||
|
||||
conn.execute_batch(
|
||||
"DROP INDEX IF EXISTS idx_items_note;
|
||||
DROP TABLE checklist_items;",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bring the database up to the latest schema. Idempotent.
|
||||
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives
|
||||
// only on a tag, so the column was written by a picker nothing read and read by nothing
|
||||
// at all. `labels.color` is untouched — that is the colour that survived.
|
||||
//
|
||||
// The saved-filter sweep is the second half and not optional. `params` is opaque JSON
|
||||
// and a stored view could carry `"color": "teal"`; with the facet gone that key would
|
||||
// sit there forever, and a view that silently filters on a field the app no longer has
|
||||
// is worse than one that visibly lost a criterion. Guarded on `json_valid` because a
|
||||
// corrupt blob must keep whatever it holds, not become NULL.
|
||||
//
|
||||
// The second guard is a LIKE and not `json_extract(...) IS NOT NULL`, which is the
|
||||
// obvious way to write it and is a trap: SQLite does not promise to short-circuit AND,
|
||||
// so `json_extract` can be evaluated against the very rows `json_valid` was there to
|
||||
// exclude — and on malformed input it does not return NULL, it RAISES, which would
|
||||
// abort the migration for every other row too. `LIKE` is total over any text.
|
||||
const SCHEMA_V9: &str = r#"
|
||||
ALTER TABLE notes DROP COLUMN color;
|
||||
|
||||
UPDATE saved_filters
|
||||
SET params = json_remove(params, '$.color')
|
||||
WHERE json_valid(params)
|
||||
AND params LIKE '%"color"%';
|
||||
"#;
|
||||
|
||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
@@ -212,5 +305,195 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(SCHEMA_V7)?;
|
||||
conn.execute_batch("PRAGMA user_version = 7;")?;
|
||||
}
|
||||
if version < 8 {
|
||||
migrate_v8(conn)?;
|
||||
conn.execute_batch("PRAGMA user_version = 8;")?;
|
||||
}
|
||||
if version < 9 {
|
||||
conn.execute_batch(SCHEMA_V9)?;
|
||||
conn.execute_batch("PRAGMA user_version = 9;")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A database as it stood before M304 — items still in their own table.
|
||||
fn v7_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open");
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
|
||||
for batch in [
|
||||
SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7,
|
||||
] {
|
||||
conn.execute_batch(batch).expect("batch");
|
||||
}
|
||||
conn.execute_batch("PRAGMA user_version = 7;").expect("v7");
|
||||
conn
|
||||
}
|
||||
|
||||
fn add_note(conn: &Connection, id: &str, body: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, body, created_at, updated_at)
|
||||
VALUES (?1, ?2, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')",
|
||||
params![id, body],
|
||||
)
|
||||
.expect("note");
|
||||
}
|
||||
|
||||
fn add_item(conn: &Connection, note: &str, text: &str, checked: bool, pos: i64) {
|
||||
conn.execute(
|
||||
"INSERT INTO checklist_items (id, note_id, text, checked, position)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![format!("{note}-{pos}"), note, text, checked, pos],
|
||||
)
|
||||
.expect("item");
|
||||
}
|
||||
|
||||
fn body_of(conn: &Connection, id: &str) -> String {
|
||||
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))
|
||||
.expect("body")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_folds_items_into_the_body() {
|
||||
let conn = v7_db();
|
||||
add_note(&conn, "n1", "shopping");
|
||||
add_item(&conn, "n1", "milk", false, 0);
|
||||
add_item(&conn, "n1", "eggs", true, 1);
|
||||
|
||||
migrate(&conn).expect("migrate");
|
||||
|
||||
// Prose, blank line, list — the layout _note_markdown already exports, so an
|
||||
// export taken before this migration and one taken after agree byte for byte.
|
||||
assert_eq!(body_of(&conn, "n1"), "shopping\n\n- [ ] milk\n- [x] eggs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_keeps_a_list_only_note_whole() {
|
||||
let conn = v7_db();
|
||||
add_note(&conn, "n1", "");
|
||||
add_item(&conn, "n1", "milk", false, 0);
|
||||
|
||||
migrate(&conn).expect("migrate");
|
||||
|
||||
assert_eq!(body_of(&conn, "n1"), "- [ ] milk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_leaves_timestamps_alone() {
|
||||
// The whole reason this needs no sync: the server folds the same rows the same
|
||||
// way, so both sides already agree. Marking notes dirty would push a body the
|
||||
// server has, from every device at once.
|
||||
let conn = v7_db();
|
||||
add_note(&conn, "n1", "note");
|
||||
add_item(&conn, "n1", "milk", false, 0);
|
||||
|
||||
migrate(&conn).expect("migrate");
|
||||
|
||||
let (updated, dirty): (String, i64) = conn
|
||||
.query_row(
|
||||
"SELECT updated_at, dirty FROM notes WHERE id = 'n1'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.expect("row");
|
||||
assert_eq!(updated, "2026-01-01T00:00:00.000Z");
|
||||
assert_eq!(dirty, 1); // as inserted, not raised by the migration
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_drops_the_table_and_is_idempotent() {
|
||||
let conn = v7_db();
|
||||
add_note(&conn, "n1", "note");
|
||||
migrate(&conn).expect("migrate");
|
||||
migrate(&conn).expect("again");
|
||||
|
||||
let exists: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='checklist_items'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("count");
|
||||
assert_eq!(exists, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_database_reaches_the_latest_version() {
|
||||
let conn = Connection::open_in_memory().expect("open");
|
||||
migrate(&conn).expect("migrate");
|
||||
let version: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.expect("version");
|
||||
assert_eq!(version, 9);
|
||||
}
|
||||
|
||||
/// The column is gone, not merely unread. Asserted by asking SQLite rather than by
|
||||
/// reading a row: a SELECT that omits `color` would pass either way.
|
||||
#[test]
|
||||
fn v9_drops_the_note_colour_column() {
|
||||
let conn = Connection::open_in_memory().expect("open");
|
||||
migrate(&conn).expect("migrate");
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(notes)").expect("pragma");
|
||||
let columns: Vec<String> = stmt
|
||||
.query_map([], |r| r.get::<_, String>(1))
|
||||
.expect("query")
|
||||
.collect::<rusqlite::Result<Vec<String>>>()
|
||||
.expect("collect");
|
||||
assert!(!columns.iter().any(|c| c == "color"));
|
||||
// The one that survived. Getting this wrong would take every tag's colour with
|
||||
// it, which is the whole thing M315 was keeping.
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
|
||||
let label_columns: Vec<String> = stmt
|
||||
.query_map([], |r| r.get::<_, String>(1))
|
||||
.expect("query")
|
||||
.collect::<rusqlite::Result<Vec<String>>>()
|
||||
.expect("collect");
|
||||
assert!(label_columns.iter().any(|c| c == "color"));
|
||||
}
|
||||
|
||||
/// A stored view that filtered on colour loses that criterion and keeps the rest.
|
||||
/// The alternative — leaving the key — is a lens that silently narrows on a field
|
||||
/// the app no longer has and never says why it returned nothing.
|
||||
#[test]
|
||||
fn v9_sweeps_colour_out_of_saved_filters() {
|
||||
let conn = Connection::open_in_memory().expect("open");
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
|
||||
for batch in [
|
||||
SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7,
|
||||
] {
|
||||
conn.execute_batch(batch).expect("schema");
|
||||
}
|
||||
conn.execute_batch("PRAGMA user_version = 8;").expect("v8");
|
||||
for (id, params) in [
|
||||
("a", r#"{"color":"teal","q":"milk"}"#),
|
||||
("b", r#"{"q":"eggs"}"#),
|
||||
// Not JSON at all. It must come out UNCHANGED rather than NULL — a blob
|
||||
// this migration cannot read is not a blob it gets to destroy.
|
||||
("c", "not json"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO saved_filters (id, name, params, created_at)
|
||||
VALUES (?1, ?1, ?2, '2026-08-28T00:00:00.000Z')",
|
||||
params![id, params],
|
||||
)
|
||||
.expect("seed");
|
||||
}
|
||||
|
||||
migrate(&conn).expect("migrate");
|
||||
|
||||
let read = |id: &str| -> String {
|
||||
conn.query_row(
|
||||
"SELECT params FROM saved_filters WHERE id = ?1",
|
||||
[id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("read")
|
||||
};
|
||||
assert_eq!(read("a"), r#"{"q":"milk"}"#);
|
||||
assert_eq!(read("b"), r#"{"q":"eggs"}"#);
|
||||
assert_eq!(read("c"), "not json");
|
||||
}
|
||||
}
|
||||
|
||||
+206
-102
@@ -9,7 +9,7 @@
|
||||
|
||||
use chrono::{DateTime, Duration, SecondsFormat, Utc};
|
||||
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::local::derive;
|
||||
@@ -24,24 +24,25 @@ fn new_id() -> 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
|
||||
/// twice, and they have to agree or a synced note is called different things on either
|
||||
/// side of the wire.
|
||||
///
|
||||
/// Pure, and given the items rather than fetching them: every caller has already
|
||||
/// loaded them, so a query here would be a second trip for something already in hand.
|
||||
fn display_title(body: &str, items: &[ChecklistItem]) -> String {
|
||||
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
|
||||
return line.to_string();
|
||||
/// It no longer needs the items, because the items ARE lines of the body now (M304).
|
||||
/// What it needs instead is to strip the task marker off: a list-only note is still
|
||||
/// named by its first item, and calling that note "- [ ] milk" would be showing
|
||||
/// someone the storage rather than the note. An empty item is skipped rather than
|
||||
/// naming the note "", which is what a half-typed list would otherwise do.
|
||||
fn display_title(body: &str) -> String {
|
||||
for line in body.lines() {
|
||||
let text = derive::strip_marker(line.trim()).trim();
|
||||
if !text.is_empty() {
|
||||
return text.to_string();
|
||||
}
|
||||
}
|
||||
items
|
||||
.iter()
|
||||
.map(|i| i.text.trim())
|
||||
.find(|t| !t.is_empty())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn escape_like(s: &str) -> String {
|
||||
@@ -69,19 +70,24 @@ fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLab
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn load_items(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<ChecklistItem>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, text, checked, position FROM checklist_items WHERE note_id = ?1 ORDER BY position ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([note_id], |r| {
|
||||
Ok(ChecklistItem {
|
||||
id: r.get(0)?,
|
||||
text: r.get(1)?,
|
||||
checked: r.get(2)?,
|
||||
position: r.get(3)?,
|
||||
/// The note's checklist, read out of its body. No query, because there is no table.
|
||||
///
|
||||
/// A `- [ ] milk` line IS the item (M304). The id is the item's ORDINAL rather than a
|
||||
/// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and
|
||||
/// checked and never an id, and both sides replaced the whole list on every sync. It
|
||||
/// is also exactly what the rewriters in `derive` take, so a UI holding an id can act
|
||||
/// on it directly.
|
||||
fn items_of(body: &str) -> Vec<ChecklistItem> {
|
||||
derive::extract_items(body)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| ChecklistItem {
|
||||
id: i.to_string(),
|
||||
text: item.text,
|
||||
checked: item.checked,
|
||||
position: i as i64,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> {
|
||||
@@ -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> {
|
||||
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",
|
||||
[id],
|
||||
|r| {
|
||||
@@ -144,14 +150,13 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
id: r.get(0)?,
|
||||
display_title: String::new(), // filled below — it may need a query
|
||||
body,
|
||||
color: r.get(2)?,
|
||||
position: r.get(3)?,
|
||||
pinned: r.get(4)?,
|
||||
archived: r.get(5)?,
|
||||
trashed: r.get(6)?,
|
||||
deleted_at: r.get(11)?,
|
||||
remind_at: r.get(7)?,
|
||||
recurrence: r.get(8)?,
|
||||
position: r.get(2)?,
|
||||
pinned: r.get(3)?,
|
||||
archived: r.get(4)?,
|
||||
trashed: r.get(5)?,
|
||||
deleted_at: r.get(10)?,
|
||||
remind_at: r.get(6)?,
|
||||
recurrence: r.get(7)?,
|
||||
labels: Vec::new(),
|
||||
items: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
@@ -162,11 +167,10 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
},
|
||||
)?;
|
||||
note.labels = load_labels(conn, id)?;
|
||||
note.items = load_items(conn, id)?;
|
||||
note.items = items_of(¬e.body);
|
||||
note.attachments = load_attachments(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(¬e.body, ¬e.items);
|
||||
note.display_title = display_title(¬e.body);
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
@@ -200,34 +204,89 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Re-sync the note's `via_tag` labels to exactly the `#tags` in its body.
|
||||
fn sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
|
||||
let tags = derive::extract_tags(body);
|
||||
let mut desired: Vec<String> = Vec::with_capacity(tags.len());
|
||||
for t in &tags {
|
||||
desired.push(find_or_create_label(conn, t)?);
|
||||
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
|
||||
/// the shortened body back.
|
||||
///
|
||||
/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it
|
||||
/// now rewrites `notes.body`, and every caller writes the body just before calling —
|
||||
/// so this overwrites what they wrote, on purpose.
|
||||
///
|
||||
/// `display_title` needs no attention here, unlike on the server: the core derives it
|
||||
/// on READ (see `display_title` above, called from `load_note`) rather than storing
|
||||
/// it, so there is no persisted copy to go stale.
|
||||
///
|
||||
/// The two kinds of tag are handled differently, and that difference IS what `via_tag`
|
||||
/// means from here on — backed by text still in the body:
|
||||
///
|
||||
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it any
|
||||
/// more, and the way to remove it becomes the chip's ×.
|
||||
/// inline left in place, attached via_tag = 1, still detached when its text
|
||||
/// goes. Unchanged from before.
|
||||
///
|
||||
/// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`.
|
||||
fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
|
||||
let (standalone, inline, lifted) = derive::lift_standalone_tags(body);
|
||||
|
||||
let mut standalone_ids: Vec<String> = Vec::with_capacity(standalone.len());
|
||||
for name in &standalone {
|
||||
standalone_ids.push(find_or_create_label(conn, name)?);
|
||||
}
|
||||
let mut inline_ids: Vec<String> = Vec::with_capacity(inline.len());
|
||||
for name in &inline {
|
||||
inline_ids.push(find_or_create_label(conn, name)?);
|
||||
}
|
||||
|
||||
let current: Vec<String> = {
|
||||
let current: Vec<(String, bool)> = {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 1")?;
|
||||
let rows = stmt.query_map([note_id], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
conn.prepare("SELECT label_id, via_tag FROM note_labels WHERE note_id = ?1")?;
|
||||
let rows = stmt.query_map([note_id], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, bool>(1)?))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<(String, bool)>>>()?
|
||||
};
|
||||
for lid in ¤t {
|
||||
if !desired.contains(lid) {
|
||||
|
||||
for (lid, via_tag) in ¤t {
|
||||
if !*via_tag {
|
||||
continue; // manual already: a #tag of the same name changes nothing
|
||||
}
|
||||
if standalone_ids.contains(lid) {
|
||||
// It GRADUATED. The text backing it is about to go, so the row has to
|
||||
// become the record instead — and BEFORE the delete below, or the same row
|
||||
// is dropped for no longer being in the body. That is the bug a naive lift
|
||||
// has, and it silently loses the tag.
|
||||
conn.execute(
|
||||
"UPDATE note_labels SET via_tag = 0 WHERE note_id = ?1 AND label_id = ?2",
|
||||
params![note_id, lid],
|
||||
)?;
|
||||
} else if !inline_ids.contains(lid) {
|
||||
conn.execute(
|
||||
"DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1",
|
||||
params![note_id, lid],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for lid in &desired {
|
||||
|
||||
// OR IGNORE leaves a label already attached in ANY form alone, which is what keeps
|
||||
// a manually-added label of the same name manual.
|
||||
for lid in &standalone_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)",
|
||||
params![note_id, lid],
|
||||
)?;
|
||||
}
|
||||
for lid in &inline_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)",
|
||||
params![note_id, lid],
|
||||
)?;
|
||||
}
|
||||
|
||||
if lifted != body {
|
||||
conn.execute(
|
||||
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||
params![lifted, note_id],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -267,10 +326,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
|
||||
binds.push(pat.clone());
|
||||
binds.push(pat);
|
||||
}
|
||||
if let Some(c) = f.color.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND color = ?");
|
||||
binds.push(c.to_string());
|
||||
}
|
||||
if f.has_reminder == Some(true) {
|
||||
sql.push_str(" AND remind_at IS NOT NULL");
|
||||
}
|
||||
@@ -358,23 +413,62 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
|
||||
params![id, input.body, input.color, position, ts],
|
||||
)?;
|
||||
// Items fold into the body rather than into rows of their own. Callers still hand
|
||||
// them over separately — the importer has a list, not a blob — but where they end
|
||||
// up is one place.
|
||||
let mut body = input.body.clone();
|
||||
if let Some(items) = &input.items {
|
||||
for (i, text) in items.iter().enumerate() {
|
||||
conn.execute(
|
||||
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![new_id(), id, text, i as i64],
|
||||
)?;
|
||||
for text in items {
|
||||
body = derive::append_item(&body, text, false);
|
||||
}
|
||||
}
|
||||
sync_tags(conn, &id, &input.body)?;
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, body, position, created_at, updated_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?4, 1)",
|
||||
params![id, body, position, ts],
|
||||
)?;
|
||||
// The FOLDED body, not the input one: an item can carry a #tag too.
|
||||
lift_and_sync_tags(conn, &id, &body)?;
|
||||
load_note(conn, &id)
|
||||
}
|
||||
|
||||
/// How long one editing session is assumed to last.
|
||||
///
|
||||
/// Inside this window a note's body may be written any number of times and only the
|
||||
/// FIRST write snapshots. That is what makes an idle-debounced autosave affordable:
|
||||
/// a write costs a write, not a write plus a revision.
|
||||
const REVISION_WINDOW_MINUTES: i64 = 10;
|
||||
|
||||
/// Whether a body change earns a snapshot of the pre-edit body.
|
||||
///
|
||||
/// Two conditions. The body must actually differ — re-saving identical text is not a
|
||||
/// version of anything. And the note must not already carry a revision from this
|
||||
/// editing session.
|
||||
///
|
||||
/// The session rule is what keeps version history worth reading. Because
|
||||
/// [`snapshot_revision`] stores the body as it was BEFORE the edit, the first write
|
||||
/// of a session captures the note as you found it, and every write after it inside
|
||||
/// the window adds nothing. One revision per sitting falls out of the window on its
|
||||
/// own — no "commit" the client has to declare, and no protocol surface to carry it,
|
||||
/// which matters because sync-apply takes this same path.
|
||||
fn should_snapshot(conn: &Connection, id: &str, new_body: &str) -> rusqlite::Result<bool> {
|
||||
let current: String =
|
||||
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
|
||||
if current == new_body {
|
||||
return Ok(false);
|
||||
}
|
||||
// String comparison, not date maths: timestamps are RFC3339 UTC with a fixed
|
||||
// millisecond field (see the module header), so lexical order IS chronological.
|
||||
let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES))
|
||||
.to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
let recent: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM note_revisions WHERE note_id = ?1 AND created_at >= ?2",
|
||||
params![id, cutoff],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(recent == 0)
|
||||
}
|
||||
|
||||
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
let body: String =
|
||||
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
|
||||
@@ -391,9 +485,12 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
|
||||
.as_object()
|
||||
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
|
||||
|
||||
// Snapshot the pre-edit body before changing it (version history).
|
||||
if obj.contains_key("body") {
|
||||
snapshot_revision(conn, id)?;
|
||||
// Snapshot the pre-edit body before changing it (version history) — but only
|
||||
// once per editing session, and only if it actually changed. See should_snapshot.
|
||||
if let Some(body) = obj.get("body").and_then(|v| v.as_str()) {
|
||||
if should_snapshot(conn, id, body)? {
|
||||
snapshot_revision(conn, id)?;
|
||||
}
|
||||
}
|
||||
|
||||
for (k, v) in obj {
|
||||
@@ -404,12 +501,7 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
|
||||
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||
params![body, id],
|
||||
)?;
|
||||
sync_tags(conn, id, body)?;
|
||||
}
|
||||
"color" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?;
|
||||
}
|
||||
lift_and_sync_tags(conn, id, body)?;
|
||||
}
|
||||
"pinned" => {
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- checklist items: every one of these is a body edit ---------------------
|
||||
//
|
||||
// They keep their own names and signatures because the FFI, the Tauri commands and
|
||||
// the REST shape all speak in items, and a checklist is still a thing a note HAS.
|
||||
// What changed is where it is kept. Routing all three through `update_note` rather
|
||||
// than writing the body directly is what gives them revision snapshotting, `#tag`
|
||||
// re-derivation and the dirty/updated_at bookkeeping without any of it being
|
||||
// written a second time here.
|
||||
|
||||
fn note_body(conn: &Connection, id: &str) -> rusqlite::Result<String> {
|
||||
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))
|
||||
}
|
||||
|
||||
/// An item's id is its ordinal (see [items_of]). Anything else is a stale id from a
|
||||
/// UI that has not reloaded, and the right answer to those is to do nothing.
|
||||
fn item_index(item_id: &str) -> Option<usize> {
|
||||
item_id.parse::<usize>().ok()
|
||||
}
|
||||
|
||||
fn set_body(conn: &Connection, id: &str, body: String) -> rusqlite::Result<Note> {
|
||||
update_note(conn, id, &json!({ "body": body }))
|
||||
}
|
||||
|
||||
pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> {
|
||||
let pos: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM checklist_items WHERE note_id = ?1",
|
||||
[id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![new_id(), id, text, pos],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
let body = note_body(conn, id)?;
|
||||
set_body(conn, id, derive::append_item(&body, text, false))
|
||||
}
|
||||
|
||||
pub fn update_item(
|
||||
@@ -528,29 +634,27 @@ pub fn update_item(
|
||||
item_id: &str,
|
||||
changes: &Value,
|
||||
) -> rusqlite::Result<Note> {
|
||||
let index = match item_index(item_id) {
|
||||
Some(i) => i,
|
||||
None => return load_note(conn, id),
|
||||
};
|
||||
let mut body = note_body(conn, id)?;
|
||||
if let Some(text) = changes.get("text").and_then(Value::as_str) {
|
||||
conn.execute(
|
||||
"UPDATE checklist_items SET text = ?1 WHERE id = ?2 AND note_id = ?3",
|
||||
params![text, item_id, id],
|
||||
)?;
|
||||
body = derive::set_item_text(&body, index, text);
|
||||
}
|
||||
if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
|
||||
conn.execute(
|
||||
"UPDATE checklist_items SET checked = ?1 WHERE id = ?2 AND note_id = ?3",
|
||||
params![checked, item_id, id],
|
||||
)?;
|
||||
body = derive::set_item_checked(&body, index, checked);
|
||||
}
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
set_body(conn, id, body)
|
||||
}
|
||||
|
||||
pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> {
|
||||
conn.execute(
|
||||
"DELETE FROM checklist_items WHERE id = ?1 AND note_id = ?2",
|
||||
params![item_id, id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
let index = match item_index(item_id) {
|
||||
Some(i) => i,
|
||||
None => return load_note(conn, id),
|
||||
};
|
||||
let body = note_body(conn, id)?;
|
||||
set_body(conn, id, derive::remove_item(&body, index))
|
||||
}
|
||||
|
||||
pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> {
|
||||
@@ -668,7 +772,7 @@ pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::
|
||||
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||
params![body, id],
|
||||
)?;
|
||||
sync_tags(conn, id, &body)?;
|
||||
lift_and_sync_tags(conn, id, &body)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
+16
-2
@@ -19,11 +19,25 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// link rather than degrading it.
|
||||
|
||||
+23
-76
@@ -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
|
||||
// never changes, and the server's copy is the same value anyway.
|
||||
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,
|
||||
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
|
||||
body = excluded.body,
|
||||
color = excluded.color,
|
||||
position = excluded.position,
|
||||
pinned = excluded.pinned,
|
||||
archived = excluded.archived,
|
||||
@@ -260,7 +259,6 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
params![
|
||||
note.id,
|
||||
note.body,
|
||||
note.color,
|
||||
note.position,
|
||||
note.pinned,
|
||||
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,
|
||||
// so "what the server sent" IS the complete set. Diffing would be more code and
|
||||
// could leave behind a row the server no longer has.
|
||||
replace_items(conn, note)?;
|
||||
replace_attachments(conn, note)?;
|
||||
replace_previews(conn, note)?;
|
||||
replace_labels(conn, note)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_items(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM checklist_items WHERE note_id = ?1",
|
||||
params![note.id],
|
||||
)?;
|
||||
for (index, item) in note.items.iter().enumerate() {
|
||||
conn.execute(
|
||||
"INSERT INTO checklist_items (id, note_id, text, checked, position)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
item.id,
|
||||
note.id,
|
||||
item.text,
|
||||
item.checked,
|
||||
position_of(item.position, index)
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_attachments(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM attachments WHERE note_id = ?1",
|
||||
@@ -393,16 +369,6 @@ fn ensure_label_stub(conn: &Connection, label: &wire::NoteLabel) -> rusqlite::Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trust an explicit position; fall back to arrival order when the server sent 0 for
|
||||
/// everything (which is what an unordered list looks like on the wire).
|
||||
fn position_of(explicit: i64, index: usize) -> i64 {
|
||||
if explicit > 0 {
|
||||
explicit
|
||||
} else {
|
||||
index as i64
|
||||
}
|
||||
}
|
||||
|
||||
/// Loop the feed to exhaustion, starting from the persisted cursor.
|
||||
///
|
||||
/// NOTE ON ORDERING: the full cycle is push-then-pull (docs/sync.md). Running this
|
||||
@@ -495,7 +461,6 @@ mod tests {
|
||||
wire::Note {
|
||||
id: id.to_string(),
|
||||
body: "Body".into(),
|
||||
color: "default".into(),
|
||||
position: 0,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
@@ -508,12 +473,22 @@ mod tests {
|
||||
sync_revision: revision,
|
||||
purged_at: None,
|
||||
labels: vec![],
|
||||
items: vec![],
|
||||
attachments: vec![],
|
||||
previews: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment(id: &str) -> wire::Attachment {
|
||||
wire::Attachment {
|
||||
id: id.to_string(),
|
||||
url: "/blob/x".into(),
|
||||
filename: None,
|
||||
mime: "image/png".into(),
|
||||
size: None,
|
||||
sha256: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn page(notes: Vec<wire::Note>, labels: Vec<wire::Label>, cursor: i64) -> wire::ChangesPage {
|
||||
wire::ChangesPage {
|
||||
notes,
|
||||
@@ -612,35 +587,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn children_are_replaced_not_merged() {
|
||||
// Was written over checklist items; they are lines of the body now (M304), so
|
||||
// attachments carry the point instead. It is the same property either way: a
|
||||
// delta is the note's FULL current state, so a child the server dropped has to
|
||||
// disappear locally rather than linger.
|
||||
let conn = db();
|
||||
let mut first = note("n1", 1);
|
||||
first.items = vec![
|
||||
wire::Item {
|
||||
id: "i1".into(),
|
||||
text: "one".into(),
|
||||
checked: false,
|
||||
position: 0,
|
||||
},
|
||||
wire::Item {
|
||||
id: "i2".into(),
|
||||
text: "two".into(),
|
||||
checked: false,
|
||||
position: 1,
|
||||
},
|
||||
];
|
||||
first.attachments = vec![attachment("a1"), attachment("a2")];
|
||||
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);
|
||||
second.items = vec![wire::Item {
|
||||
id: "i1".into(),
|
||||
text: "one".into(),
|
||||
checked: true,
|
||||
position: 0,
|
||||
}];
|
||||
second.attachments = vec![attachment("a1")];
|
||||
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]
|
||||
@@ -810,23 +770,10 @@ mod tests {
|
||||
fn a_page_that_fails_leaves_the_cursor_untouched() {
|
||||
// Atomicity is the whole resumability story: a cursor committed ahead of its
|
||||
// data would skip those rows forever. Force a failure with a duplicate
|
||||
// checklist-item id inside one page.
|
||||
// attachment id inside one page.
|
||||
let conn = db();
|
||||
let mut n = note("n1", 3);
|
||||
n.items = vec![
|
||||
wire::Item {
|
||||
id: "dup".into(),
|
||||
text: "one".into(),
|
||||
checked: false,
|
||||
position: 0,
|
||||
},
|
||||
wire::Item {
|
||||
id: "dup".into(),
|
||||
text: "two".into(),
|
||||
checked: false,
|
||||
position: 1,
|
||||
},
|
||||
];
|
||||
n.attachments = vec![attachment("dup"), attachment("dup")];
|
||||
assert!(apply_page(&conn, &page(vec![n], vec![], 3)).is_err());
|
||||
assert_eq!(state::read(&conn).expect("state").last_cursor, 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
|
||||
|
||||
+15
-38
@@ -64,6 +64,8 @@ pub struct Change {
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
/// A LABEL's colour. A note has none since M315, so a note change leaves this
|
||||
/// `None` and the key never reaches the wire.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -79,8 +81,6 @@ pub struct Change {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub items: Option<Vec<ItemOut>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
@@ -103,7 +103,6 @@ impl Change {
|
||||
remind_at: None,
|
||||
recurrence: None,
|
||||
position: None,
|
||||
items: None,
|
||||
label_ids: None,
|
||||
created_at: None,
|
||||
name: None,
|
||||
@@ -111,12 +110,6 @@ impl Change {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ItemOut {
|
||||
pub text: String,
|
||||
pub checked: bool,
|
||||
}
|
||||
|
||||
// --- incoming results --------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -201,7 +194,6 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
|
||||
remind_at: None,
|
||||
recurrence: None,
|
||||
position: None,
|
||||
items: None,
|
||||
label_ids: None,
|
||||
created_at: None,
|
||||
})
|
||||
@@ -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.
|
||||
struct NoteRow {
|
||||
body: String,
|
||||
color: String,
|
||||
position: i64,
|
||||
pinned: bool,
|
||||
archived: bool,
|
||||
@@ -243,22 +234,21 @@ struct NoteRow {
|
||||
|
||||
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
||||
conn.query_row(
|
||||
"SELECT body, color, position, pinned, archived, trashed,
|
||||
"SELECT body, position, pinned, archived, trashed,
|
||||
remind_at, recurrence, created_at, updated_at
|
||||
FROM notes WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
Ok(NoteRow {
|
||||
body: r.get(0)?,
|
||||
color: r.get(1)?,
|
||||
position: r.get(2)?,
|
||||
pinned: r.get::<_, i64>(3)? != 0,
|
||||
archived: r.get::<_, i64>(4)? != 0,
|
||||
trashed: r.get::<_, i64>(5)? != 0,
|
||||
remind_at: r.get(6)?,
|
||||
recurrence: r.get(7)?,
|
||||
created_at: r.get(8)?,
|
||||
updated_at: r.get(9)?,
|
||||
position: r.get(1)?,
|
||||
pinned: r.get::<_, i64>(2)? != 0,
|
||||
archived: r.get::<_, i64>(3)? != 0,
|
||||
trashed: r.get::<_, i64>(4)? != 0,
|
||||
remind_at: r.get(5)?,
|
||||
recurrence: r.get(6)?,
|
||||
created_at: r.get(7)?,
|
||||
updated_at: r.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -267,19 +257,6 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
||||
fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
||||
let row = note_row(conn, id)?;
|
||||
|
||||
let items = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT text, checked FROM checklist_items WHERE note_id = ?1 ORDER BY position",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![id], |r| {
|
||||
Ok(ItemOut {
|
||||
text: r.get(0)?,
|
||||
checked: r.get::<_, i64>(1)? != 0,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<ItemOut>>>()?
|
||||
};
|
||||
|
||||
// MANUAL memberships only. Tag-sourced ones (`via_tag = 1`) are re-derived by the
|
||||
// server from the body; sending them as label_ids would convert them into manual
|
||||
// assignments that no longer disappear when the #tag is removed from the text.
|
||||
@@ -298,14 +275,14 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
||||
// server's last-write-wins comparison runs against.
|
||||
edited_at: row.updated_at,
|
||||
body: Some(row.body),
|
||||
color: Some(row.color),
|
||||
// A note has no colour to send. See the field on `Change`.
|
||||
color: None,
|
||||
pinned: Some(row.pinned),
|
||||
archived: Some(row.archived),
|
||||
trashed: Some(row.trashed),
|
||||
remind_at: row.remind_at,
|
||||
recurrence: row.recurrence,
|
||||
position: Some(row.position),
|
||||
items: Some(items),
|
||||
label_ids: Some(label_ids),
|
||||
created_at: Some(row.created_at),
|
||||
name: None,
|
||||
@@ -521,9 +498,9 @@ mod tests {
|
||||
|
||||
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
|
||||
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)
|
||||
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)",
|
||||
params![id, dirty],
|
||||
)
|
||||
|
||||
@@ -25,8 +25,6 @@ pub struct Note {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
pub position: i64,
|
||||
#[serde(default)]
|
||||
@@ -58,8 +56,6 @@ pub struct Note {
|
||||
#[serde(default)]
|
||||
pub labels: Vec<NoteLabel>,
|
||||
#[serde(default)]
|
||||
pub items: Vec<Item>,
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<Attachment>,
|
||||
#[serde(default)]
|
||||
pub previews: Vec<Preview>,
|
||||
@@ -87,17 +83,6 @@ pub struct NoteLabel {
|
||||
pub via_tag: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Item {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub checked: bool,
|
||||
#[serde(default)]
|
||||
pub position: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
|
||||
@@ -18,7 +18,7 @@ pacman system:
|
||||
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:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -64,7 +64,9 @@ DEPENDS=(webkit2gtk-4.1 gtk3)
|
||||
# this?" question unanswerable.
|
||||
# `|| true` so a miss falls through to the explicit error below rather than
|
||||
# aborting on pipefail with no explanation.
|
||||
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; }
|
||||
|
||||
# Reproducible-ish: prefer the commit date over "now" so rebuilding the same
|
||||
|
||||
@@ -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
|
||||
@@ -5,8 +5,12 @@
|
||||
# curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/dev/desktop/packaging/install.sh | sh
|
||||
#
|
||||
# Two channels, the SAME two the app's own updater offers (src-tauri/src/update.rs):
|
||||
# stable (default) — the newest tagged v* release.
|
||||
# stable (default) — the rolling build from every merge to `main`.
|
||||
# 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
|
||||
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
|
||||
#
|
||||
@@ -41,7 +45,7 @@ ThoughtSync desktop installer.
|
||||
|
||||
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`
|
||||
-h, --help this text
|
||||
|
||||
@@ -82,20 +86,23 @@ esac
|
||||
# --- resolve the release for this channel -----------------------------------
|
||||
say "Finding the latest ThoughtSync build on the $channel channel…"
|
||||
|
||||
if [ "$channel" = "dev" ]; then
|
||||
# A release whose tag never moves and whose assets are pruned to the current
|
||||
# build — so the tag alone always names the newest dev build.
|
||||
json="$(curl -fsSL "$API/releases/tags/dev" 2>/dev/null)" ||
|
||||
die "the dev channel has nothing published yet."
|
||||
else
|
||||
# Ask the stable channel's own manifest which version is current, then install
|
||||
# THAT release. This is the same file the in-app updater reads, so the installer
|
||||
# and the updater can never disagree about what `stable` means.
|
||||
#
|
||||
# Not `/releases/latest`: that returns the newest non-prerelease release by date,
|
||||
# and the `stable` pointer release (manifest only, no bundles — see
|
||||
# write-manifest.sh) is itself a non-prerelease created moments after the
|
||||
# versioned one. It would win, and it carries nothing installable.
|
||||
# ONE lookup for both channels now. Each is a release whose tag never moves and whose
|
||||
# assets are pruned to the current build, so the tag alone names the newest build on
|
||||
# that channel — which is exactly what an installer wants and what the in-app updater
|
||||
# already reads.
|
||||
json="$(curl -fsSL "$API/releases/tags/$channel" 2>/dev/null)" ||
|
||||
die "the $channel channel has nothing published yet."
|
||||
|
||||
# TRANSITIONAL — delete with the rest of the old scheme (M314 step 7).
|
||||
#
|
||||
# `stable` existed before this as a manifest-ONLY pointer: `latest.json` naming a
|
||||
# version whose bundles lived on a separate `v<version>` release. Between this commit
|
||||
# and the first merge to `main` it still looks like that, and `stable` is the DEFAULT
|
||||
# channel — so without this fallback `curl … | sh` is broken for everyone in that
|
||||
# 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)"
|
||||
stable_version="$(printf '%s' "$manifest" |
|
||||
grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 |
|
||||
@@ -104,11 +111,8 @@ else
|
||||
json="$(curl -fsSL "$API/releases/tags/v$stable_version" 2>/dev/null)" ||
|
||||
die "the stable channel names $stable_version, but there is no v$stable_version release to install."
|
||||
else
|
||||
# No stable pointer yet — the channel predates the updater. Fall back to the
|
||||
# newest non-prerelease release, which is what stable meant before there was
|
||||
# a manifest to ask.
|
||||
json="$(curl -fsSL "$API/releases/latest" 2>/dev/null)" ||
|
||||
die "no stable release published yet — try --channel dev, or ask the maintainer to tag one."
|
||||
die "no stable build published yet — try --channel dev, or merge to main."
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -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
|
||||
# — otherwise someone following the instructions here lands on a tagged build and
|
||||
# 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
|
||||
INSTALL_TAIL='sh -s -- --channel dev'
|
||||
# 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
|
||||
# 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.'
|
||||
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
|
||||
INSTALL_TAIL='sh'
|
||||
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"}
|
||||
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.
|
||||
release="$(ALLOW_CODES=409 api POST "$API/releases" -H "Content-Type: application/json" -d "$BODY")"
|
||||
RELEASE_ID="$(printf '%s' "$release" | first_id || true)"
|
||||
|
||||
@@ -25,14 +25,15 @@ set -euo pipefail
|
||||
: "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}"
|
||||
: "${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
|
||||
# (`v0.2.0`) holds the real assets, but the app can only read a URL that never
|
||||
# changes — so the same manifest is also attached to a `stable` release whose tag is
|
||||
# permanent and whose only content is this file. It points back at the versioned
|
||||
# assets, so nothing is duplicated.
|
||||
MANIFEST_TAG="${MANIFEST_TAG:-$RELEASE_TAG}"
|
||||
# There used to be: `MANIFEST_TAG` let the manifest live on a `stable` pointer
|
||||
# release while the bundles sat on a versioned `v*` one, because the app can only
|
||||
# read a URL that never changes and a versioned tag is not that. M314 step 3 made
|
||||
# `stable` a rolling release that holds its own bundles, exactly like `dev`, so the
|
||||
# split had nothing left to bridge — and a parameter that can only ever be passed
|
||||
# its own default is a branch nobody exercises and a comment that goes stale.
|
||||
|
||||
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
AUTH=(-H "Authorization: token $GITHUB_TOKEN")
|
||||
@@ -115,25 +116,11 @@ pub_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
echo "==> Manifest:"
|
||||
cat "$work/latest.json"
|
||||
|
||||
# --- resolve the release the manifest is published TO ------------------------
|
||||
if [ "$MANIFEST_TAG" = "$RELEASE_TAG" ]; then
|
||||
target_id="$release_id"
|
||||
target_assets="$assets"
|
||||
else
|
||||
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
|
||||
# The manifest goes on the same release the bundles were just read from — which is
|
||||
# also the one `publish-release.sh` created or refreshed moments earlier, so it is
|
||||
# guaranteed to exist by the time this runs.
|
||||
target_id="$release_id"
|
||||
target_assets="$assets"
|
||||
|
||||
# Replace rather than duplicate: Forgejo rejects a second asset with the same name,
|
||||
# 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
|
||||
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" \
|
||||
-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 ---------------------------
|
||||
#
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
[package]
|
||||
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"
|
||||
description = "ThoughtSync desktop — local-first Keep-style thought capture"
|
||||
authors = ["bvandeusen"]
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
//! In-app updates (M10.9).
|
||||
//!
|
||||
//! Two channels, because two audiences: `stable` follows tagged `v*` releases,
|
||||
//! `dev` follows every green push. Each reads a `latest.json` published as an asset
|
||||
//! on a release whose TAG NEVER CHANGES — verified necessary, because Forgejo has no
|
||||
//! `/releases/latest/download/<asset>` route (it 404s), so "newest" cannot be named
|
||||
//! in a URL. A fixed tag can.
|
||||
//! Two channels, because two audiences: `stable` follows every merge to `main`,
|
||||
//! `dev` follows every green push to `dev`. Each reads a `latest.json` published as
|
||||
//! an asset on a release whose TAG NEVER CHANGES — verified necessary, because
|
||||
//! Forgejo has no `/releases/latest/download/<asset>` route (it 404s), so "newest"
|
||||
//! 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:
|
||||
//! this app is usable having never linked a server, and an install that can't reach
|
||||
|
||||
@@ -15,13 +15,19 @@ cannot talk to.
|
||||
|
||||
**Normally: nowhere. It is already in the image.**
|
||||
|
||||
CI fetches the newest published Android build into every server image it builds,
|
||||
so `:dev`, `:latest` and `:<version>` all ship a client. `docker compose pull &&
|
||||
docker compose up -d` delivers a new server and a new client together, and there
|
||||
is nothing to copy.
|
||||
CI fetches the published Android build into every server image it builds, so
|
||||
`:dev` and `:latest` both ship a client. `docker compose pull && docker compose
|
||||
up -d` delivers a new server and a new client together, and there is nothing to
|
||||
copy.
|
||||
|
||||
A versioned image therefore carries the *newest* client rather than one pinned to
|
||||
that version. That is deliberate: the two negotiate a sync protocol version
|
||||
**The channel is a property of the image you run.** A `:dev` image bakes in the
|
||||
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
|
||||
pinning.
|
||||
|
||||
|
||||
+20
-5
@@ -52,6 +52,15 @@ syncs everything else.
|
||||
### The policy
|
||||
|
||||
- **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`
|
||||
name. Do **not** raise a minimum. Old clients keep working.
|
||||
- **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
|
||||
{ "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,
|
||||
"position": 0, "items": [ {"text": "...", "checked": false} ],
|
||||
"recurrence": null, "position": 0,
|
||||
"label_ids": ["<uuid>", ...], "created_at": "<iso8601, on create>" }
|
||||
```
|
||||
|
||||
- **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.
|
||||
- **Whole-note semantics.** A note upsert carries the client's *full* current
|
||||
state (not a partial patch) — the server overwrites all scalar fields, replaces
|
||||
items, and sets manual label memberships from `label_ids` (tag-sourced labels
|
||||
are re-derived from the body). `#tags` are recomputed server-side.
|
||||
state (not a partial patch) — the server overwrites all scalar fields and sets
|
||||
manual label memberships from `label_ids` (tag-sourced labels are re-derived
|
||||
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
|
||||
`trashed: true`.
|
||||
- **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
// consume. Client-side logic (list reconciliation, optimistic updates, toasts)
|
||||
// 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 { Label } from "../stores/labels";
|
||||
import type { SavedFilter } from "../stores/savedFilters";
|
||||
@@ -33,13 +32,12 @@ export interface NoteListQuery {
|
||||
|
||||
export interface NoteCreateInput {
|
||||
body: string;
|
||||
color: NoteColor;
|
||||
items?: string[];
|
||||
}
|
||||
|
||||
// The mutable subset of a note (PATCH /api/notes/:id).
|
||||
export type NoteChanges = Partial<
|
||||
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
>;
|
||||
|
||||
export interface ChecklistItemChanges {
|
||||
|
||||
@@ -31,7 +31,6 @@ function notesQuery(q: NoteListQuery): string {
|
||||
if (q.labelId) params.append("label", q.labelId);
|
||||
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?.color) params.set("color", q.facets.color);
|
||||
if (q.facets?.has_reminder) params.set("has_reminder", "true");
|
||||
if (q.facets?.has_attachment) params.set("has_attachment", "true");
|
||||
if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
|
||||
|
||||
@@ -14,7 +14,7 @@ import ImportNotes from "./ImportNotes.vue";
|
||||
import LabelsModal from "./LabelsModal.vue";
|
||||
import { isDesktop } from "../desktop/bridge";
|
||||
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 router = useRouter();
|
||||
@@ -173,8 +173,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
|
||||
|
||||
function labelDot(color: string): string {
|
||||
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
|
||||
// The drawer's tag list. Same resolution as every other chip and dot — a tag that is
|
||||
// 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
|
||||
@@ -443,7 +445,7 @@ async function signOut() {
|
||||
>
|
||||
<span
|
||||
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 class="truncate">{{ lb.name }}</span>
|
||||
</RouterLink>
|
||||
|
||||
@@ -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>
|
||||
@@ -7,7 +7,6 @@ import { useUiStore } from "../stores/ui";
|
||||
import type { NoteFacets } from "../stores/notes";
|
||||
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
|
||||
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";
|
||||
|
||||
// A dead-simple facet bar over the board: color + labels + has-reminder
|
||||
@@ -32,9 +31,6 @@ function patch(p: Partial<NoteFacets>) {
|
||||
function clearAll() {
|
||||
void router.replace({ path: "/", query: {} });
|
||||
}
|
||||
function setColor(c: NoteColor) {
|
||||
patch({ color: facets.value.color === c ? undefined : c });
|
||||
}
|
||||
function toggleLabel(id: string) {
|
||||
const cur = facets.value.label ?? [];
|
||||
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"
|
||||
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">
|
||||
<span class="w-16 shrink-0 text-xs text-neutral-400">Labels</span>
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
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 Icon from "./Icon.vue";
|
||||
|
||||
@@ -25,8 +31,12 @@ async function rename(id: string, value: string) {
|
||||
if (name) await labels.rename(id, name);
|
||||
}
|
||||
|
||||
function labelDot(color: string): string {
|
||||
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
|
||||
// Shows the colour the tag ACTUALLY wears, derived from its name when nobody has
|
||||
// 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) {
|
||||
@@ -75,8 +85,8 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
<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="labelDot(lb.color)"
|
||||
:title="`Color: ${NOTE_COLOR_LABELS[(lb.color as NoteColor)] ?? lb.color}`"
|
||||
:class="labelDot(lb)"
|
||||
:title="`Color: ${NOTE_COLOR_LABELS[resolveLabelColor(lb)]}`"
|
||||
aria-label="Change label color"
|
||||
@click="openColor(lb.id)"
|
||||
/>
|
||||
@@ -120,7 +130,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
: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], lb.color === key ? 'ring-2 ring-brand' : '']"
|
||||
:class="[NOTE_SWATCH_CLASSES[key], resolveLabelColor(lb) === key ? 'ring-2 ring-brand' : '']"
|
||||
@click="pickColor(lb.id, key)"
|
||||
/>
|
||||
</div>
|
||||
@@ -140,7 +150,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
>
|
||||
<span
|
||||
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 class="truncate">{{ t.name }}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
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
|
||||
// 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>
|
||||
|
||||
<!-- 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'"
|
||||
class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10"
|
||||
>{{ 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
|
||||
>
|
||||
|
||||
@@ -3,34 +3,73 @@ import { computed } from "vue";
|
||||
import { parseMarkdown } from "../notes/markdown";
|
||||
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));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5 break-words">
|
||||
<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>
|
||||
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4>
|
||||
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
|
||||
<h3 v-if="b.type === 'h1'" class="text-base font-bold">
|
||||
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
|
||||
</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
|
||||
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"
|
||||
>
|
||||
<MarkdownInline :tokens="b.inline ?? []" />
|
||||
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
<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"
|
||||
>{{ 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import {
|
||||
LABEL_CHIP_CLASSES,
|
||||
NOTE_CARD_CLASSES,
|
||||
NOTE_COLOR_KEYS,
|
||||
NOTE_COLOR_LABELS,
|
||||
NOTE_SWATCH_CLASSES,
|
||||
type NoteColor,
|
||||
} from "../notes/colors";
|
||||
import { NOTE_CARD_SURFACE, labelChipClasses } from "../notes/colors";
|
||||
import type { Note } from "../stores/notes";
|
||||
import Icon from "./Icon.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import MarkdownText from "./MarkdownText.vue";
|
||||
import NoteChecklist from "./NoteChecklist.vue";
|
||||
import {
|
||||
cardIdAt,
|
||||
draggingId,
|
||||
@@ -94,6 +86,15 @@ const bodyPreview = computed(() => {
|
||||
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);
|
||||
|
||||
// --- 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");
|
||||
}
|
||||
|
||||
function cardClass(color: NoteColor): string {
|
||||
return NOTE_CARD_CLASSES[color] ?? NOTE_CARD_CLASSES.default;
|
||||
}
|
||||
// Only the tags the BODY is not already showing. `via_tag` means exactly "backed by
|
||||
// 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 {
|
||||
return LABEL_CHIP_CLASSES[color as NoteColor] ?? LABEL_CHIP_CLASSES.default;
|
||||
}
|
||||
|
||||
// Per-card color popover (recolor without opening the editor).
|
||||
const colorOpen = ref(false);
|
||||
|
||||
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);
|
||||
// The colour the operator stored for each of this note's tags, keyed by lowercased
|
||||
// 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>>(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const lb of props.note.labels) map[lb.name.toLowerCase()] = lb.color;
|
||||
return map;
|
||||
});
|
||||
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
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="[
|
||||
cardClass(note.color),
|
||||
NOTE_CARD_SURFACE,
|
||||
dragging ? 'opacity-40' : '',
|
||||
dragOver
|
||||
? '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"
|
||||
>
|
||||
<!-- 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
|
||||
v-if="firstImage"
|
||||
:src="firstImage.url"
|
||||
@@ -263,7 +307,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
blank and the link is never unreachable. -->
|
||||
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
|
||||
<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>
|
||||
<p
|
||||
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 />
|
||||
</div>
|
||||
|
||||
<NoteChecklist
|
||||
v-if="note.items.length"
|
||||
:class="note.body ? 'mt-2' : ''"
|
||||
:note-id="note.id"
|
||||
: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>
|
||||
<!-- No separate checklist block any more. A checklist is lines of the body (M304),
|
||||
so MarkdownText above draws it in place — which is what lets a list sit between
|
||||
two paragraphs instead of always after them. Rendering both would have shown
|
||||
every list twice. -->
|
||||
|
||||
<div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
@@ -410,19 +441,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
</button>
|
||||
</template>
|
||||
<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
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
@@ -453,24 +471,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,16 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import ColorPicker from "./ColorPicker.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import LabelPicker from "./LabelPicker.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import NoteChecklist from "./NoteChecklist.vue";
|
||||
import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
||||
import { takeMorphOrigin } from "../composables/useEditorMorph";
|
||||
import { prefersReducedMotion } from "../composables/useReducedMotion";
|
||||
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
|
||||
// "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 noteId = ref<string | null>(props.note?.id ?? null);
|
||||
const body = ref(props.note?.body ?? props.initialBody);
|
||||
const color = ref<NoteColor>(props.note?.color ?? "default");
|
||||
// BLOCKS rather than one string, because a checklist item is drawn as a real checkbox
|
||||
// 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] : []);
|
||||
// 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
|
||||
// on when the note already carries items, and when someone asks for one.
|
||||
const checklistOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
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 uploadError = ref("");
|
||||
|
||||
// Baseline for edit-mode change detection (save only when text actually changed).
|
||||
const baseline = ref<{ body: string; color: NoteColor }>({
|
||||
body: props.note?.body ?? "",
|
||||
color: (props.note?.color ?? "default") as NoteColor,
|
||||
});
|
||||
const baseline = ref<{ body: string }>({ body: props.note?.body ?? "" });
|
||||
|
||||
const isCreate = computed(() => noteId.value === null);
|
||||
const hasContent = computed(() => body.value.trim() !== "");
|
||||
@@ -55,7 +91,6 @@ const draftNote = computed<Note>(() => ({
|
||||
id: "",
|
||||
display_title: "",
|
||||
body: body.value,
|
||||
color: color.value,
|
||||
position: 0,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
@@ -75,15 +110,6 @@ const liveNote = computed<Note>(() =>
|
||||
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? 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…";
|
||||
|
||||
// Keep local state in sync when the edited note changes (modal reused for another note).
|
||||
@@ -91,18 +117,17 @@ watch(
|
||||
() => props.note,
|
||||
(n) => {
|
||||
noteId.value = n?.id ?? null;
|
||||
body.value = n?.body ?? "";
|
||||
color.value = (n?.color ?? "default") as NoteColor;
|
||||
setBody(n?.body ?? "");
|
||||
labelList.value = n ? [...n.labels] : [];
|
||||
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
|
||||
baseline.value = { body: n?.body ?? "" };
|
||||
},
|
||||
);
|
||||
|
||||
// ---- persistence ----
|
||||
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;
|
||||
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
|
||||
@@ -129,12 +154,12 @@ async function flush(): Promise<void> {
|
||||
}
|
||||
const b = baseline.value;
|
||||
const nextBody = body.value;
|
||||
const changed = nextBody !== b.body || color.value !== b.color;
|
||||
const changed = nextBody !== b.body;
|
||||
if (!changed) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value });
|
||||
baseline.value = { body: nextBody, color: color.value };
|
||||
await notes.saveEdit(noteId.value as string, { body: nextBody });
|
||||
baseline.value = { body: nextBody };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -142,11 +167,9 @@ async function flush(): Promise<void> {
|
||||
|
||||
function resetCompose(): void {
|
||||
noteId.value = null;
|
||||
body.value = "";
|
||||
color.value = "default";
|
||||
setBody("");
|
||||
labelList.value = [];
|
||||
checklistOpen.value = false;
|
||||
baseline.value = { body: "", color: "default" };
|
||||
baseline.value = { body: "" };
|
||||
uploadError.value = "";
|
||||
}
|
||||
|
||||
@@ -157,7 +180,9 @@ async function commitAndContinue(): Promise<void> {
|
||||
await flush();
|
||||
resetCompose();
|
||||
await nextTick();
|
||||
bodyInput.value?.focus();
|
||||
growAll();
|
||||
const first = blocks.value[0];
|
||||
await focusBlock(first ? first.id : null);
|
||||
}
|
||||
// ---- open/close animation (M7) ----
|
||||
//
|
||||
@@ -227,21 +252,85 @@ function onBackdropMousedown(): void {
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
const el = bodyInput.value;
|
||||
el?.focus();
|
||||
// Put the caret after any seeded text (type-to-compose) so typing continues cleanly.
|
||||
if (el) el.selectionStart = el.selectionEnd = el.value.length;
|
||||
growAll();
|
||||
// The LAST block, with the caret after its text: opening a note means continuing it,
|
||||
// and type-to-compose seeds text that should be typed straight on from.
|
||||
const last = blocks.value[blocks.value.length - 1];
|
||||
await focusBlock(last ? last.id : null);
|
||||
});
|
||||
|
||||
function onBodyKeydown(e: KeyboardEvent) {
|
||||
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
|
||||
/** Editing one block: replace its text, leave every other block alone. */
|
||||
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) {
|
||||
e.preventDefault();
|
||||
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 ----
|
||||
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
|
||||
async function onReminderChange(e: Event) {
|
||||
@@ -279,20 +368,19 @@ async function onLabelsChange(next: NoteLabel[]) {
|
||||
async function removeLabel(id: string) {
|
||||
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 ----
|
||||
//
|
||||
// Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its
|
||||
// body and gains a place to put items. Persists the draft first for the same reason
|
||||
// attaching a file does — an item needs a note to belong to.
|
||||
async function addChecklist() {
|
||||
if (checklistOpen.value) return;
|
||||
const id = await ensureDraft();
|
||||
if (!id) return;
|
||||
checklistOpen.value = true;
|
||||
// Appends an empty item and puts the caret in it. Unlike every other toolbar button
|
||||
// this one needs NO persisted note to hang anything off — a checklist is part of the
|
||||
// body (M304), so it works on an empty compose box the moment it opens.
|
||||
//
|
||||
// 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 be the one being looked at by
|
||||
// the time this runs.
|
||||
function addChecklist(): void {
|
||||
const next = plusTask(blocks.value);
|
||||
blocks.value = next.blocks;
|
||||
void focusBlock(next.focus);
|
||||
}
|
||||
|
||||
// ---- attachments ----
|
||||
@@ -371,9 +459,8 @@ async function restoreRevisionAt(revId: string) {
|
||||
const id = noteId.value;
|
||||
if (!id) return;
|
||||
const updated = await notes.restoreRevision(id, revId);
|
||||
body.value = updated.body;
|
||||
color.value = updated.color;
|
||||
baseline.value = { body: updated.body, color: updated.color };
|
||||
setBody(updated.body);
|
||||
baseline.value = { body: updated.body };
|
||||
void loadRevisions(); // the pre-restore state became a new revision
|
||||
}
|
||||
function revLabel(iso: string | null): string {
|
||||
@@ -474,31 +561,65 @@ function revPreview(rev: NoteRevision): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="bodyInput"
|
||||
v-model="body"
|
||||
rows="8"
|
||||
:placeholder="bodyPlaceholder"
|
||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||
@keydown="onBodyKeydown"
|
||||
/>
|
||||
<!-- Below the body, not instead of it. -->
|
||||
<NoteChecklist
|
||||
v-if="showChecklist"
|
||||
class="py-1"
|
||||
:note-id="liveNote.id"
|
||||
:items="liveNote.items"
|
||||
editable
|
||||
/>
|
||||
<!-- The body, as fields and checkboxes rather than as markup. A checklist
|
||||
item is a real input; a run of prose is one textarea, so typing a
|
||||
paragraph still feels like typing a paragraph. -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<template v-for="(block, i) in blocks" :key="block.id">
|
||||
<div v-if="block.checked !== null" class="group/item flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 shrink-0 accent-brand"
|
||||
:checked="block.checked"
|
||||
:aria-label="block.text || 'Checklist item'"
|
||||
@change="setChecked(i, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<input
|
||||
: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">
|
||||
<span
|
||||
v-for="lb in labelList"
|
||||
:key="lb.id"
|
||||
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
|
||||
v-if="!lb.via_tag"
|
||||
type="button"
|
||||
@@ -583,8 +704,10 @@ function revPreview(rev: NoteRevision): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
|
||||
<ColorPicker v-model="color" />
|
||||
<!-- `justify-end`, not `justify-between`: the colour picker sat on the left of
|
||||
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">
|
||||
<button
|
||||
v-if="richEnabled && !liveNote.trashed"
|
||||
@@ -598,7 +721,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
</button>
|
||||
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
|
||||
<button
|
||||
v-if="richEnabled && !liveNote.trashed && !showChecklist"
|
||||
v-if="!liveNote.trashed"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Add a checklist"
|
||||
|
||||
@@ -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
@@ -17,18 +17,10 @@ export const NOTE_COLOR_KEYS = [
|
||||
|
||||
export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
|
||||
|
||||
export const NOTE_CARD_CLASSES: Record<NoteColor, string> = {
|
||||
default: "bg-white border-neutral-200 dark:bg-neutral-900 dark:border-neutral-700",
|
||||
red: "bg-red-50 border-red-200 dark:bg-red-950/40 dark:border-red-900",
|
||||
orange: "bg-orange-50 border-orange-200 dark:bg-orange-950/40 dark:border-orange-900",
|
||||
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",
|
||||
};
|
||||
/** Membership test for a colour key arriving from the server, which may be newer
|
||||
* than this client. Once a lookup in a card-fill table; since M315 there is no such
|
||||
* table, and this guards a LABEL's stored colour on its way into the palette. */
|
||||
const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS);
|
||||
|
||||
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
// Label chip tints (bg + readable text), keyed by the same color vocabulary.
|
||||
export const LABEL_CHIP_CLASSES: Record<NoteColor, string> = {
|
||||
default: "bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300",
|
||||
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",
|
||||
yellow: "bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300",
|
||||
green: "bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300",
|
||||
teal: "bg-teal-100 text-teal-700 dark:bg-teal-950/50 dark:text-teal-300",
|
||||
blue: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300",
|
||||
purple: "bg-purple-100 text-purple-700 dark:bg-purple-950/50 dark:text-purple-300",
|
||||
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",
|
||||
// A chip's SHELL: its fill and its hairline edge, keyed by the colour vocabulary. The
|
||||
// INK is not here — see TAG_TEXT_CLASSES, which one table now serves both a chip's text
|
||||
// and a `#tag` left in the prose. Compose the two with `labelChipClasses`.
|
||||
//
|
||||
// THE RING IS NOT DECORATION. A chip's fill measures 1.02-1.26 against the card in
|
||||
// light and 1.02-1.73 in dark — that is to say, very nearly nothing. The pill's shape
|
||||
// is the edge; the fill only tints it. (Dark red is the extreme at 1.02, which is
|
||||
// invisible: without the ring that chip would be loose text.) An edge holds the shape
|
||||
// against any background, where shifting the fill only moves which card it collides
|
||||
// with.
|
||||
//
|
||||
// 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).
|
||||
// Mid-tone hues read on both the light and dark graph background.
|
||||
export const NOTE_NODE_FILL: Record<NoteColor, string> = {
|
||||
default: "#9ca3af",
|
||||
red: "#ef4444",
|
||||
orange: "#f97316",
|
||||
yellow: "#f59e0b",
|
||||
green: "#22c55e",
|
||||
teal: "#14b8a6",
|
||||
blue: "#3b82f6",
|
||||
purple: "#a855f7",
|
||||
pink: "#ec4899",
|
||||
gray: "#6b7280",
|
||||
// THE INK A TAG IS DRAWN IN — one table, for a `#tag` left in the prose AND for a
|
||||
// chip's text. It was two, and the split was real while it lasted: a chip carried 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, so inline got `-800` and the chip kept `-700`.
|
||||
//
|
||||
// M315 removed the twenty card fills the split was solving for, and this is the payoff:
|
||||
// against ONE card surface both jobs can take the same value. `-800`/`-300` is the one
|
||||
// they take, and the direction is deliberate — the inline token is the common case
|
||||
// (since M311 a tag whose text is in the body is drawn where it was typed and NOT
|
||||
// repeated as a chip), so collapsing onto the inline column leaves what is seen most
|
||||
// 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> = {
|
||||
default: "Default",
|
||||
red: "Red",
|
||||
@@ -84,3 +149,153 @@ export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
|
||||
pink: "Pink",
|
||||
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";
|
||||
|
||||
@@ -15,8 +15,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
|
||||
const f: NoteFacets = {};
|
||||
const text = one(q.q);
|
||||
if (text) f.q = text;
|
||||
const color = one(q.color);
|
||||
if (color) f.color = color;
|
||||
if (labels.length) f.label = labels;
|
||||
if (one(q.has_reminder) === "true") f.has_reminder = 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 {
|
||||
const q: LocationQueryRaw = {};
|
||||
if (f.q) q.q = f.q;
|
||||
if (f.color) q.color = f.color;
|
||||
if (f.label?.length) q.label = f.label;
|
||||
if (f.has_reminder) q.has_reminder = "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 {
|
||||
let n = 0;
|
||||
if (f.q) n++;
|
||||
if (f.color) n++;
|
||||
n += f.label?.length ?? 0;
|
||||
if (f.has_reminder) n++;
|
||||
if (f.has_attachment) n++;
|
||||
|
||||
@@ -7,16 +7,30 @@
|
||||
// a heading.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Flat (non-discriminated) shape on purpose — keeps template type-checking simple.
|
||||
export interface Block {
|
||||
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre";
|
||||
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre" | "task";
|
||||
inline?: InlineToken[];
|
||||
items?: InlineToken[][];
|
||||
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;
|
||||
@@ -25,7 +39,22 @@ export interface Block {
|
||||
// `[[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
|
||||
// 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[] {
|
||||
const tokens: InlineToken[] = [];
|
||||
@@ -37,6 +66,7 @@ export function parseInline(text: string): InlineToken[] {
|
||||
const raw = m[0];
|
||||
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[5]) tokens.push({ type: "tag", value: raw.slice(1) });
|
||||
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
|
||||
last = m.index + raw.length;
|
||||
}
|
||||
@@ -44,11 +74,45 @@ export function parseInline(text: string): InlineToken[] {
|
||||
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[] {
|
||||
const lines = (text ?? "").split("\n");
|
||||
const blocks: Block[] = [];
|
||||
let paragraph: string[] = [];
|
||||
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 = () => {
|
||||
if (paragraph.length) {
|
||||
@@ -96,6 +160,25 @@ export function parseMarkdown(text: string): Block[] {
|
||||
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.
|
||||
if (/^\s*[-*+]\s+/.test(line)) {
|
||||
flushPara();
|
||||
|
||||
@@ -2,14 +2,12 @@ import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { repo } from "../adapters";
|
||||
import { useUiStore } from "./ui";
|
||||
import type { NoteColor } from "../notes/colors";
|
||||
|
||||
export type NoteView = "active" | "archived" | "trash";
|
||||
// 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.
|
||||
export interface NoteFacets {
|
||||
q?: string;
|
||||
color?: string;
|
||||
label?: string[];
|
||||
has_reminder?: boolean;
|
||||
has_attachment?: boolean;
|
||||
@@ -21,8 +19,13 @@ export interface NoteLabel {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
// True when this label is attached because of a #tag in the note body (kept in
|
||||
// sync with the text); false = added manually via the picker.
|
||||
// True when the label is backed by text STILL IN THE BODY — a `#tag` written
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -65,7 +68,6 @@ export interface Note {
|
||||
// (server-derived). Every note has one, so every note has something to be called.
|
||||
display_title: string;
|
||||
body: string;
|
||||
color: NoteColor;
|
||||
position: number;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
@@ -131,11 +133,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function create(input: {
|
||||
body: string;
|
||||
color: NoteColor;
|
||||
items?: string[];
|
||||
}): Promise<Note> {
|
||||
async function create(input: { body: string; items?: string[] }): Promise<Note> {
|
||||
const note = await repo.notes.create(input);
|
||||
reconcile(note);
|
||||
return note;
|
||||
@@ -143,9 +141,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
|
||||
async function mutate(
|
||||
id: string,
|
||||
changes: Partial<
|
||||
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
>,
|
||||
changes: Partial<Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">>,
|
||||
): Promise<void> {
|
||||
reconcile(await repo.notes.update(id, changes));
|
||||
}
|
||||
@@ -156,10 +152,9 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
if (archived)
|
||||
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 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> {
|
||||
reconcile(await repo.notes.completeReminder(id));
|
||||
@@ -274,7 +269,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
create,
|
||||
setPinned,
|
||||
setArchived,
|
||||
setColor,
|
||||
setReminder,
|
||||
setRecurrence,
|
||||
completeReminder,
|
||||
|
||||
@@ -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
|
||||
* markup, because "how the board moves" is one idea even though the pinned, other
|
||||
* and non-board grids are three TransitionGroups.
|
||||
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/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.
|
||||
#
|
||||
# EVERY LOOKUP HERE MUST SUCCEED EVEN WHEN IT FINDS NOTHING. That is what the `|| true`
|
||||
# on each pipeline is for, and it is load-bearing rather than defensive noise.
|
||||
#
|
||||
# An empty channel is a REAL state this guard is written to pass — `[ -z "$published" ]`
|
||||
# further down says so in as many words. But the value is captured as
|
||||
# `published="$(published_for ...)"`, and under `set -e` a command substitution that
|
||||
# exits non-zero kills the script before that branch is ever reached. Silently, too:
|
||||
# everything the pipeline would have said went into the capture rather than the log.
|
||||
#
|
||||
# WHICH COMMAND THE PIPELINE HAPPENS TO END ON decides whether that fires, which is the
|
||||
# part worth remembering. `sed` on empty input exits 0; `grep` exits 1. Three of these
|
||||
# four lookups end in `sed` and were fine. The one that ends in `grep -oE '[0-9]+$'` —
|
||||
# Android's version_code — was not, and it failed the whole Android lane on the first
|
||||
# merge to `main` (run 4857): exit 1, no output, 0.16 seconds, on the one channel that
|
||||
# had no APK published yet. Its three neighbours hid it until then.
|
||||
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/'; } || true
|
||||
;;
|
||||
android)
|
||||
{ fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
|
||||
| grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \
|
||||
| grep -oE '[0-9]+$'; } || true
|
||||
;;
|
||||
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/'; } || true
|
||||
;;
|
||||
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."
|
||||
Executable
+79
@@ -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._"
|
||||
Executable
+76
@@ -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
|
||||
Executable
+238
@@ -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
|
||||
@@ -1,3 +1,10 @@
|
||||
"""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"
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .models.note import NOTE_COLORS
|
||||
|
||||
# Notes and labels share one colour palette (their sets were identical). NOTE_COLORS
|
||||
# is the canonical vocabulary (defined on the model); this module is the single home
|
||||
# for the "clamp to the palette" normalizer so notes.py, labels.py and sync.py stop
|
||||
# each carrying their own copy.
|
||||
# The colour palette, and the one place that clamps to it.
|
||||
#
|
||||
# It lived on `models/note.py` until M315, when a note stopped having a colour. A
|
||||
# palette defined on the model that lost one would be a standing invitation to put the
|
||||
# column back; here it reads as what it now is — a LABEL's vocabulary, shared with the
|
||||
# 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"]
|
||||
|
||||
|
||||
def normalize_color(color: object) -> str:
|
||||
"""Return `color` if it's a known palette key, else the default. One definition
|
||||
for both notes and labels."""
|
||||
"""Return `color` if it's a known palette key, else the default.
|
||||
|
||||
`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"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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).
|
||||
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 sqlalchemy import select
|
||||
|
||||
@@ -9,7 +9,6 @@ from . import ( # noqa: F401
|
||||
label,
|
||||
note,
|
||||
note_attachment,
|
||||
note_item,
|
||||
note_link_preview,
|
||||
note_revision,
|
||||
saved_filter,
|
||||
|
||||
@@ -10,22 +10,6 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
from . import Base
|
||||
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):
|
||||
__tablename__ = "notes"
|
||||
__table_args__ = (
|
||||
@@ -45,8 +29,6 @@ class Note(Base):
|
||||
# the full-text vector can weight it above the rest of the body.
|
||||
display_title: 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.
|
||||
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
|
||||
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||
@@ -75,7 +57,6 @@ class Note(Base):
|
||||
"id": str(self.id),
|
||||
"display_title": self.display_title,
|
||||
"body": self.body,
|
||||
"color": self.color,
|
||||
"position": self.position,
|
||||
"pinned": self.pinned,
|
||||
"archived": self.archived,
|
||||
|
||||
@@ -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())
|
||||
@@ -12,8 +12,9 @@ from . import Base
|
||||
|
||||
class SavedFilter(Base):
|
||||
"""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
|
||||
GET /api/notes query (q/color/labels/has_reminder/has_attachment/date range)."""
|
||||
click — e.g. "#ideas with a reminder". `params` is a JSON-encoded facet dict
|
||||
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"
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from sqlalchemy import func, literal_column, select
|
||||
|
||||
from ..acl import visible_to_user
|
||||
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 ..config import Config
|
||||
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.note import Note
|
||||
from ..models.note_attachment import NoteAttachment
|
||||
from ..models.note_item import NoteItem
|
||||
from ..models.note_link_preview import NoteLinkPreview
|
||||
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 ..retention import purge_note
|
||||
from ..settings import get_setting
|
||||
@@ -65,11 +72,11 @@ from .import_export import (
|
||||
_usec_to_dt,
|
||||
)
|
||||
from .tags import (
|
||||
_reconcile_tags,
|
||||
_lift_and_reconcile_tags,
|
||||
parse_tags,
|
||||
)
|
||||
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__ = [
|
||||
"bp",
|
||||
@@ -80,7 +87,7 @@ __all__ = [
|
||||
"normalize_color",
|
||||
"normalize_recurrence",
|
||||
"next_occurrence",
|
||||
"_reconcile_tags",
|
||||
"_lift_and_reconcile_tags",
|
||||
"_serialize_notes",
|
||||
"_safe_filename",
|
||||
"_attachment_ext",
|
||||
@@ -101,7 +108,6 @@ async def list_notes():
|
||||
# Combinable facet filters (all optional, AND-ed together) — the rich-search /
|
||||
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
|
||||
label_params = request.args.getlist("label")
|
||||
color = request.args.get("color")
|
||||
has_reminder = coerce_bool(request.args.get("has_reminder"))
|
||||
has_attachment = coerce_bool(request.args.get("has_attachment"))
|
||||
query_text = (request.args.get("q") or "").strip()
|
||||
@@ -121,10 +127,6 @@ async def list_notes():
|
||||
if lid is None:
|
||||
return json_error("invalid label", 400)
|
||||
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:
|
||||
stmt = stmt.where(Note.remind_at.is_not(None))
|
||||
if has_attachment:
|
||||
@@ -224,7 +226,6 @@ async def export_notes():
|
||||
).all()
|
||||
ids = [n.id for n in notes_list]
|
||||
labels_map = await _labels_for_notes(db, ids)
|
||||
items_map = await _items_for_notes(db, ids)
|
||||
att_rows = (
|
||||
(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:
|
||||
for n in notes_list:
|
||||
labels = labels_map.get(n.id, [])
|
||||
items = items_map.get(n.id, [])
|
||||
atts = att_by_note.get(n.id, [])
|
||||
short = str(n.id)[:8]
|
||||
payload["notes"].append(
|
||||
@@ -254,7 +254,6 @@ async def export_notes():
|
||||
"id": str(n.id),
|
||||
"display_title": n.display_title,
|
||||
"body": n.body,
|
||||
"color": n.color,
|
||||
"pinned": n.pinned,
|
||||
"archived": n.archived,
|
||||
"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,
|
||||
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
|
||||
"labels": [lb["name"] for lb in labels],
|
||||
"items": [{"text": it["text"], "checked": it["checked"]} for it in items],
|
||||
"attachments": [
|
||||
{"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:
|
||||
src = Config.media_root() / a.path
|
||||
if src.is_file():
|
||||
@@ -384,24 +382,6 @@ async def reorder_notes():
|
||||
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("")
|
||||
@login_required
|
||||
async def create_note():
|
||||
@@ -418,18 +398,20 @@ async def create_note():
|
||||
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(
|
||||
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,
|
||||
color=normalize_color(data.get("color")),
|
||||
position=int(max_pos) + 1,
|
||||
)
|
||||
db.add(note)
|
||||
await db.flush() # assign note.id before writing items/links
|
||||
for pos, text in enumerate(item_texts):
|
||||
db.add(NoteItem(note_id=note.id, text=text, position=pos))
|
||||
await _reconcile_tags(db, note)
|
||||
await db.flush() # assign note.id before writing links
|
||||
# The FOLDED body: an item can carry a #tag too.
|
||||
await _lift_and_reconcile_tags(db, note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
# 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
|
||||
if "body" in data and isinstance(data["body"], str):
|
||||
note.body = data["body"]
|
||||
if "color" in data:
|
||||
note.color = normalize_color(data["color"])
|
||||
if "pinned" in data:
|
||||
note.pinned = bool(data["pinned"])
|
||||
if "archived" in data:
|
||||
@@ -483,10 +463,12 @@ async def update_note(note_id: str):
|
||||
if "recurrence" in data:
|
||||
note.recurrence = normalize_recurrence(data["recurrence"])
|
||||
if "body" in data:
|
||||
note.display_title = await _name_for(db, note)
|
||||
await _reconcile_tags(db, note)
|
||||
# Version history: snapshot the PRE-edit body whenever it changed.
|
||||
if note.body != old_body:
|
||||
note.display_title = derive_display_title(note.body)
|
||||
await _lift_and_reconcile_tags(db, note)
|
||||
# Version history: snapshot the PRE-edit body, once per editing session
|
||||
# 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))
|
||||
await db.commit()
|
||||
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.
|
||||
db.add(NoteRevision(note_id=note.id, body=note.body))
|
||||
note.body = rev.body
|
||||
note.display_title = await _name_for(db, note)
|
||||
await _reconcile_tags(db, note)
|
||||
note.display_title = derive_display_title(note.body)
|
||||
await _lift_and_reconcile_tags(db, note)
|
||||
await db.commit()
|
||||
await db.refresh(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))
|
||||
|
||||
|
||||
async def _get_item(db, note: Note, item_id: str) -> NoteItem | None:
|
||||
iid = parse_uuid(item_id)
|
||||
if iid is None:
|
||||
def _item_index(item_id: str) -> int | None:
|
||||
"""An item's id is its ordinal (see serialize.items_of). Anything else is a stale
|
||||
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 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")
|
||||
@@ -588,70 +594,49 @@ async def add_item(note_id: str):
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
max_pos = await db.scalar(
|
||||
select(func.coalesce(func.max(NoteItem.position), -1)).where(NoteItem.note_id == note.id)
|
||||
)
|
||||
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
|
||||
response = await _rewrite_body(db, note, append_item(note.body, text))
|
||||
return response, 201
|
||||
|
||||
|
||||
@bp.patch("/<note_id>/items/<item_id>")
|
||||
@login_required
|
||||
async def update_item(note_id: str, item_id: str):
|
||||
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:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
item = await _get_item(db, note, item_id)
|
||||
if item is None:
|
||||
if index >= len(parse_items(note.body)):
|
||||
return not_found()
|
||||
body = note.body
|
||||
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:
|
||||
item.checked = bool(data["checked"])
|
||||
await db.commit()
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
body = set_item_checked(body, index, bool(data["checked"]))
|
||||
return await _rewrite_body(db, note, body)
|
||||
|
||||
|
||||
@bp.delete("/<note_id>/items/<item_id>")
|
||||
@login_required
|
||||
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:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
item = await _get_item(db, note, item_id)
|
||||
if item is None:
|
||||
if index >= len(parse_items(note.body)):
|
||||
return not_found()
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
return await _rewrite_body(db, note, remove_item(note.body, index))
|
||||
|
||||
|
||||
@bp.post("/<note_id>/items/reorder")
|
||||
@login_required
|
||||
async def reorder_items(note_id: str):
|
||||
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))
|
||||
# The reorder route is gone with M304. Reordering a checklist is moving a line, which
|
||||
# is something a text editor already does and no client ever called this for — the
|
||||
# only reference to it in the tree was a test asserting the route existed.
|
||||
|
||||
|
||||
@bp.post("/<note_id>/attachments")
|
||||
|
||||
@@ -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}"
|
||||
@@ -3,6 +3,8 @@ board-filter narrowing, the owner-scoped fetch, and filename/slug sanitizers use
|
||||
both the attachment routes and the importer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .checklist import strip_marker
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
@@ -19,29 +21,36 @@ VALID_FILTERS = {"active", "archived", "trash"}
|
||||
DISPLAY_TITLE_CAP = 200
|
||||
|
||||
|
||||
def derive_display_title(body: str | None, first_item: str | None = None) -> str:
|
||||
"""The note's display NAME: the first non-empty line of the body, else the first
|
||||
checklist item's text (both trimmed and length-capped).
|
||||
def derive_display_title(body: str | None) -> str:
|
||||
"""The note's display NAME: the first line of the body that says anything.
|
||||
|
||||
There is no explicit title to prefer any more (M13 step 3) — a note is a body plus
|
||||
optional items, and its name is simply the first thing written in it. Persisted as
|
||||
notes.display_title so search results and export filenames have something to say.
|
||||
There is no explicit title to prefer any more (M13 step 3) — a note is a body, and
|
||||
its name is simply the first thing written in it. Persisted as notes.display_title
|
||||
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
|
||||
otherwise have no name at all, which is exactly the hole that made removing the
|
||||
title unsafe before checklists stopped being their own kind of thing.
|
||||
The old `first_item` fallback is gone with M304: items ARE body lines now, so a
|
||||
list-only note is named by its first item without anyone having to arrange it. What
|
||||
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():
|
||||
stripped = line.strip()
|
||||
stripped = strip_marker(line.strip()).strip()
|
||||
if stripped:
|
||||
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:
|
||||
"""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
|
||||
|
||||
|
||||
|
||||
@@ -14,13 +14,11 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..colors import normalize_color
|
||||
from ..common import parse_dt
|
||||
from ..config import Config
|
||||
from ..models.label import NoteLabel
|
||||
from ..models.note import Note
|
||||
from ..models.note_attachment import NoteAttachment
|
||||
from ..models.note_item import NoteItem
|
||||
from .helpers import (
|
||||
ALLOWED_IMAGE_MIMES,
|
||||
_attachment_ext,
|
||||
@@ -28,18 +26,18 @@ from .helpers import (
|
||||
derive_display_title,
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
The authoritative machine format is notes.json; this is for reading/portability."""
|
||||
fm = ["---"]
|
||||
fm.append(f"display_name: {note.display_title}")
|
||||
if labels:
|
||||
fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]")
|
||||
fm.append(f"color: {note.color}")
|
||||
if note.pinned:
|
||||
fm.append("pinned: true")
|
||||
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.
|
||||
if note.body:
|
||||
fm.append(note.body)
|
||||
if items:
|
||||
if note.body:
|
||||
fm.append("")
|
||||
for it in items:
|
||||
fm.append(f"- [{'x' if it['checked'] else ' '}] {it['text']}")
|
||||
# No separate items block any more. The body already ends with those exact lines
|
||||
# (M304) — this function is where their layout was decided, and appending them a
|
||||
# second time would double every checklist in an export.
|
||||
return "\n".join(fm) + "\n"
|
||||
|
||||
|
||||
# --- 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
|
||||
# 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()}
|
||||
@@ -102,7 +80,6 @@ def _native_spec(n: dict) -> dict:
|
||||
return {
|
||||
"title": n.get("title"),
|
||||
"body": n.get("body") or "",
|
||||
"color": n.get("color"),
|
||||
"pinned": bool(n.get("pinned")),
|
||||
"archived": bool(n.get("archived")),
|
||||
"trashed": False, # export only includes live notes
|
||||
@@ -158,7 +135,6 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
|
||||
return {
|
||||
"title": kn.get("title"),
|
||||
"body": body,
|
||||
"color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"),
|
||||
"pinned": bool(kn.get("isPinned")),
|
||||
"archived": bool(kn.get("isArchived")),
|
||||
"trashed": bool(kn.get("isTrashed")),
|
||||
@@ -294,11 +270,18 @@ async def _create_imported_note(
|
||||
if is_empty_note(body, item_texts):
|
||||
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(
|
||||
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,
|
||||
color=normalize_color(spec.get("color")),
|
||||
pinned=bool(spec.get("pinned")),
|
||||
archived=bool(spec.get("archived")),
|
||||
position=position,
|
||||
@@ -316,15 +299,10 @@ async def _create_imported_note(
|
||||
if spec.get("updated_at"):
|
||||
note.updated_at = spec["updated_at"]
|
||||
db.add(note)
|
||||
await db.flush() # assign note.id before items/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))
|
||||
await db.flush() # assign note.id before labels/attachments/links
|
||||
|
||||
# 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 []:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
@@ -340,5 +318,5 @@ async def _create_imported_note(
|
||||
if isinstance(att, dict):
|
||||
_import_attachment(db, note, zf, att, budget)
|
||||
|
||||
await _reconcile_tags(db, note)
|
||||
await _lift_and_reconcile_tags(db, note)
|
||||
return True
|
||||
|
||||
@@ -8,8 +8,8 @@ from sqlalchemy import select
|
||||
from ..models.label import Label, NoteLabel
|
||||
from ..models.note import Note
|
||||
from ..models.note_attachment import NoteAttachment
|
||||
from ..models.note_item import NoteItem
|
||||
from ..models.note_link_preview import NoteLinkPreview
|
||||
from .checklist import parse_items
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _serialize_item(item: NoteItem) -> dict:
|
||||
return {"id": str(item.id), "text": item.text, "checked": item.checked, "position": item.position}
|
||||
def items_of(body: str | None) -> list[dict]:
|
||||
"""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:
|
||||
"""Map note_id -> [checklist items] in one query, ordered by position."""
|
||||
result: dict = {}
|
||||
if not note_ids:
|
||||
return result
|
||||
items = (
|
||||
await db.scalars(
|
||||
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
|
||||
The id is the item's ORDINAL, which is what the rewriters in `checklist.py` take,
|
||||
so a client holding one can act on it directly. It also shifts when an item is
|
||||
removed — every mutation returns the reloaded note for exactly that reason.
|
||||
"""
|
||||
return [
|
||||
{"id": str(i), "text": item.text, "checked": item.checked, "position": i}
|
||||
for i, item in enumerate(parse_items(body))
|
||||
]
|
||||
|
||||
|
||||
def _attachment_url(note_id, att_id) -> str:
|
||||
@@ -108,8 +108,7 @@ async def _serialize_note(db, note: Note) -> dict:
|
||||
data = note.serialize()
|
||||
labels = await _labels_for_notes(db, [note.id])
|
||||
data["labels"] = labels.get(note.id, [])
|
||||
items = await _items_for_notes(db, [note.id])
|
||||
data["items"] = items.get(note.id, [])
|
||||
data["items"] = items_of(note.body)
|
||||
attachments = await _attachments_for_notes(db, [note.id])
|
||||
data["attachments"] = attachments.get(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:
|
||||
ids = [n.id for n in notes]
|
||||
labels_map = await _labels_for_notes(db, ids)
|
||||
items_map = await _items_for_notes(db, ids)
|
||||
|
||||
attach_map = await _attachments_for_notes(db, ids)
|
||||
preview_map = await _previews_for_notes(db, ids)
|
||||
out = []
|
||||
for n in notes:
|
||||
data = n.serialize()
|
||||
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["previews"] = preview_map.get(n.id, [])
|
||||
out.append(data)
|
||||
|
||||
+148
-31
@@ -1,6 +1,11 @@
|
||||
"""#tags — parsing note bodies and keeping the derived tag-sourced note_labels rows
|
||||
in sync with the text. Manual (picker) labels are NOT touched here (see the labeling
|
||||
module).
|
||||
"""#tags — parsing note bodies, LIFTING the standalone ones out of the text, and
|
||||
keeping the still-in-text ones in sync with the note_labels rows. Manual (picker)
|
||||
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):
|
||||
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.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
|
||||
# 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-]*)")
|
||||
|
||||
|
||||
def parse_tags(body: str | None) -> list[str]:
|
||||
"""Distinct #hashtags from a note body, in order, deduped case-insensitively.
|
||||
A tag must contain a letter, so #2024 or #_ are ignored (avoids numeric noise)."""
|
||||
if not body:
|
||||
return []
|
||||
# A fence opens or closes a code block. A `#tag` inside one is CODE — the shell
|
||||
# comment in a snippet someone pasted — and lifting it would delete a line of their
|
||||
# example. It still becomes a label, because it always has and that is a separate
|
||||
# question from whether the text may be touched.
|
||||
_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] = []
|
||||
seen: set[str] = set()
|
||||
for match in _TAG_RE.finditer(body):
|
||||
tag = match.group(1)
|
||||
if not any(c.isalpha() for c in tag):
|
||||
continue
|
||||
norm = tag.lower()
|
||||
for name in names:
|
||||
norm = name.lower()
|
||||
if norm not in seen:
|
||||
seen.add(norm)
|
||||
out.append(tag)
|
||||
out.append(name)
|
||||
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):
|
||||
"""Owner's label id for `name` (case-insensitive match), creating it if absent."""
|
||||
existing = await db.scalar(
|
||||
@@ -53,23 +128,65 @@ async def _find_or_create_label(db, owner_id, name: str):
|
||||
return label.id
|
||||
|
||||
|
||||
async def _reconcile_tags(db, note: Note) -> None:
|
||||
"""Sync tag-sourced labels (via_tag=True) with the #hashtags in the note body:
|
||||
attach labels for current tags, detach tag-labels whose #tag was removed. Manual
|
||||
picker labels (via_tag=False) are never touched."""
|
||||
tag_label_ids: set = set()
|
||||
for name in parse_tags(note.body):
|
||||
tag_label_ids.add(await _find_or_create_label(db, note.owner_id, name))
|
||||
async def _lift_and_reconcile_tags(db, note: Note) -> None:
|
||||
"""Attach the note's tag labels, LIFT its standalone tags out of the body, and
|
||||
re-derive display_title if the body moved.
|
||||
|
||||
NAMED FOR THE MUTATION. It used to be `_reconcile_tags` and only touched rows; it
|
||||
now rewrites `note.body`, and a caller that does not expect that will compute a
|
||||
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()
|
||||
attached_ids = {r.label_id for r in rows}
|
||||
# Detach tag-labels no longer backed by a #tag in the body.
|
||||
attached: set = set()
|
||||
for r in rows:
|
||||
if r.via_tag and r.label_id not in tag_label_ids:
|
||||
await db.delete(r)
|
||||
attached_ids.discard(r.label_id)
|
||||
# Attach new tags — skip labels already attached (in any form) to respect the PK
|
||||
# and leave a manually-added label of the same name as-is.
|
||||
for lid in tag_label_ids:
|
||||
if lid not in attached_ids:
|
||||
if not r.via_tag:
|
||||
attached.add(r.label_id) # manual already: a #tag of the same name changes nothing
|
||||
elif r.label_id in standalone_ids:
|
||||
# It GRADUATED. The text backing it is about to be deleted, so the row has
|
||||
# to become the record instead. This must run BEFORE the detach below, or
|
||||
# the same row would be dropped for no longer being in the body — which is
|
||||
# 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))
|
||||
attached_ids.add(lid)
|
||||
attached.add(lid)
|
||||
|
||||
if lifted != note.body:
|
||||
note.body = lifted
|
||||
note.display_title = derive_display_title(note.body)
|
||||
|
||||
@@ -32,7 +32,6 @@ from .db import session_scope
|
||||
from .models.label import NoteLabel
|
||||
from .models.note import Note
|
||||
from .models.note_attachment import NoteAttachment
|
||||
from .models.note_item import NoteItem
|
||||
from .models.note_link_preview import NoteLinkPreview
|
||||
from .models.note_revision import NoteRevision
|
||||
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.
|
||||
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(NoteItem).where(NoteItem.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(NoteRevision).where(NoteRevision.note_id == note.id))
|
||||
|
||||
@@ -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
|
||||
@@ -16,9 +16,11 @@ bp = Blueprint("saved_filters", __name__, url_prefix="/api/saved-filters")
|
||||
|
||||
NAME_CAP = 100
|
||||
# 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 = {
|
||||
"q",
|
||||
"color",
|
||||
"label", # matches the repeatable ?label= query param (stored as an array)
|
||||
"has_reminder",
|
||||
"has_attachment",
|
||||
|
||||
+29
-54
@@ -24,10 +24,10 @@ from .db import session_scope
|
||||
from .labeling import reconcile_manual_labels, resolve_owned_label_ids
|
||||
from .models.label import Label, NoteLabel
|
||||
from .models.note import Note
|
||||
from .models.note_item import NoteItem
|
||||
from .models.note_revision import NoteRevision
|
||||
from .revisions import should_snapshot
|
||||
from .notes import (
|
||||
_reconcile_tags,
|
||||
_lift_and_reconcile_tags,
|
||||
_serialize_notes,
|
||||
derive_display_title,
|
||||
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
|
||||
# ran against a half-applied v2.
|
||||
SYNC_PROTOCOL_VERSION = 2
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 2
|
||||
# v4 (M315): `color` left the note. The FLOOR DELIBERATELY DOES NOT MOVE, and v2 is
|
||||
# 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
|
||||
# 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
|
||||
whole-note, not a partial patch — the client sends its authoritative version)."""
|
||||
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.archived = bool(ch.get("archived"))
|
||||
if ch.get("trashed"):
|
||||
@@ -212,54 +220,17 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
|
||||
note.position = ch["position"]
|
||||
|
||||
|
||||
def _first_item_text(ch: dict) -> str:
|
||||
"""The first non-blank checklist item in a pushed change, or "".
|
||||
|
||||
Read straight from the payload rather than the database because the note's name is
|
||||
computed BEFORE `_apply_note_items` has written anything — and a note whose body is
|
||||
empty is named by its first item (M13 step 3).
|
||||
"""
|
||||
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))
|
||||
# `_first_item_text` and `_apply_note_items` lived here until M304. Both existed for
|
||||
# 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
|
||||
# applying the body, and naming the note is reading its first line. A client that still
|
||||
# sends an `items` array is a v2 client, and the version floor below turns it away
|
||||
# before any of this runs.
|
||||
|
||||
|
||||
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
|
||||
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")
|
||||
if not isinstance(raw, list):
|
||||
return
|
||||
@@ -314,15 +285,19 @@ async def _apply_note(db, ch: dict) -> dict:
|
||||
|
||||
old_body = note.body
|
||||
_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:
|
||||
note.updated_at = edited_at
|
||||
# Non-destructive LWW: snapshot the overwritten server body into history.
|
||||
if not creating and note.body != old_body:
|
||||
# Non-destructive LWW: snapshot the overwritten server body into history —
|
||||
# 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))
|
||||
await db.flush() # assign note.id before items/labels/links
|
||||
await _apply_note_items(db, note, ch)
|
||||
await _reconcile_tags(db, note)
|
||||
await _lift_and_reconcile_tags(db, note)
|
||||
await _apply_note_manual_labels(db, note, ch)
|
||||
await db.flush()
|
||||
await db.refresh(note, ["sync_revision"])
|
||||
|
||||
+178
-49
@@ -16,6 +16,7 @@ deployment has ever seen.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -24,20 +25,23 @@ from sqlalchemy import select, text
|
||||
from thoughtsync import ratelimit
|
||||
from thoughtsync.app import create_app
|
||||
from thoughtsync.db import dispose_engine, session_scope
|
||||
from thoughtsync.models.label import NoteLabel
|
||||
from thoughtsync.models.note import Note
|
||||
from thoughtsync.models.note_item import NoteItem
|
||||
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.notes.checklist import parse_items, set_item_checked
|
||||
from thoughtsync.notes.helpers import derive_display_title
|
||||
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
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# 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.
|
||||
_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
|
||||
@@ -157,65 +161,123 @@ async def test_the_search_vector_was_rebuilt_over_the_name(db, owner):
|
||||
assert body_only == 1
|
||||
|
||||
|
||||
async def test_a_note_keeps_both_its_body_and_its_items(db, owner):
|
||||
"""The shape M13 step 2 made normal: a note HAS a checklist, it isn't one."""
|
||||
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()
|
||||
async def test_a_note_keeps_its_prose_on_both_sides_of_its_list(db, owner):
|
||||
"""The shape M304 made expressible at all.
|
||||
|
||||
items = (
|
||||
await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id).order_by(NoteItem.position))
|
||||
).all()
|
||||
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.
|
||||
The old model could not hold this: a row had a position in a table and none in the
|
||||
text, so a checklist could only ever render AFTER the body. Prose, list, prose is
|
||||
the case that proves the storage changed, not just the styling.
|
||||
"""
|
||||
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)
|
||||
await db.flush()
|
||||
db.add(NoteItem(note_id=note.id, text="socks", position=0))
|
||||
await _lift_and_reconcile_tags(db, note)
|
||||
await db.commit()
|
||||
|
||||
# A change that says nothing about items must LEAVE them alone — absent means
|
||||
# "not telling us", not "empty".
|
||||
await _apply_note_items(db, note, {"body": "packing"})
|
||||
await db.commit()
|
||||
assert (await db.scalar(select(NoteItem.text).where(NoteItem.note_id == note.id))) == "socks"
|
||||
assert note.body == "reorganize the homepage"
|
||||
# Re-derived by the lift itself. Every caller sets display_title BEFORE calling,
|
||||
# so if the function did not do this the note would be named after a line it had
|
||||
# just deleted.
|
||||
assert note.display_title == "reorganize the homepage"
|
||||
|
||||
# An explicit list replaces them.
|
||||
await _apply_note_items(db, note, {"items": [{"text": "charger", "checked": True}]})
|
||||
await db.commit()
|
||||
rows = (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
|
||||
assert [(r.text, r.checked) for r in rows] == [("charger", True)]
|
||||
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].via_tag is False
|
||||
|
||||
|
||||
async def test_a_note_with_only_items_still_has_a_name(db, owner):
|
||||
"""The hole that made removing the title unsafe until step 2 closed it."""
|
||||
note = Note(owner_id=owner.id, body="", display_title="")
|
||||
async def test_a_tag_moved_onto_its_own_line_graduates_instead_of_vanishing(db, owner):
|
||||
"""The bug a naive lift has, pinned.
|
||||
|
||||
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)
|
||||
await db.flush()
|
||||
db.add(NoteItem(note_id=note.id, text="milk", position=0))
|
||||
await _lift_and_reconcile_tags(db, note)
|
||||
await db.commit()
|
||||
|
||||
first = await db.scalar(
|
||||
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
|
||||
)
|
||||
note.display_title = derive_display_title(note.body, first)
|
||||
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].via_tag is True
|
||||
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()
|
||||
|
||||
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["minimum"] is not None and row["maximum"] is not None
|
||||
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
@@ -4,13 +4,23 @@ import pytest
|
||||
|
||||
from thoughtsync.app import create_app
|
||||
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.notes import (
|
||||
_attachment_ext,
|
||||
_header_filename,
|
||||
_keep_spec,
|
||||
_native_spec,
|
||||
_note_markdown,
|
||||
_safe_filename,
|
||||
_slugify,
|
||||
_usec_to_dt,
|
||||
@@ -22,6 +32,7 @@ from thoughtsync.notes import (
|
||||
parse_list_items,
|
||||
parse_tags,
|
||||
)
|
||||
from thoughtsync.notes.tags import split_body_tags
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -42,7 +53,7 @@ def test_all_note_routes_registered(app):
|
||||
"reorder_notes", "create_note",
|
||||
"get_note", "update_note", "list_revisions", "restore_revision",
|
||||
"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",
|
||||
"restore_note", "delete_note",
|
||||
)
|
||||
@@ -66,16 +77,16 @@ def test_normalize_color():
|
||||
|
||||
|
||||
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"):
|
||||
assert c in NOTE_COLORS
|
||||
|
||||
|
||||
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()
|
||||
assert "title" not in s # there is no title field any more (M13 step 3)
|
||||
assert s["body"] == "b"
|
||||
assert s["color"] == "blue"
|
||||
assert s["pinned"] is True
|
||||
assert s["archived"] 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"
|
||||
|
||||
|
||||
def test_derive_display_title_falls_back_to_the_first_item():
|
||||
# What step 2 bought: a note that is only a checklist still has a name. Without
|
||||
# this it would have none at all, which is why the title could not go first.
|
||||
assert derive_display_title("", "milk") == "milk"
|
||||
assert derive_display_title(" \n ", " eggs ") == "eggs"
|
||||
# The body still wins when it has anything to say.
|
||||
assert derive_display_title("shopping", "milk") == "shopping"
|
||||
def test_derive_display_title_names_a_list_only_note():
|
||||
# The same property the old `first_item` fallback protected — a note that is only
|
||||
# a checklist still has a name — reached a different way. Items ARE body lines now
|
||||
# (M304), so the first one is simply the first line, with its marker stripped:
|
||||
# calling the note "- [ ] milk" would show someone the storage instead of the note.
|
||||
assert derive_display_title("- [ ] milk\n- [ ] eggs") == "milk"
|
||||
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():
|
||||
assert derive_display_title(None) == ""
|
||||
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():
|
||||
long = "x" * 300
|
||||
assert derive_display_title(long) == "x" * 200
|
||||
# the item fallback is capped on the same rule
|
||||
assert derive_display_title("", long) == "x" * 200
|
||||
# A first line that happens to be an item is capped on the same rule.
|
||||
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():
|
||||
@@ -332,6 +443,8 @@ def test_keep_spec_list_note_keeps_its_text_too():
|
||||
"textContent": "for the weekend",
|
||||
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
|
||||
"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",
|
||||
"isPinned": True,
|
||||
"isArchived": False,
|
||||
@@ -341,7 +454,7 @@ def test_keep_spec_list_note_keeps_its_text_too():
|
||||
}
|
||||
spec = _keep_spec(kn, "Takeout/Keep")
|
||||
assert spec["body"] == "for the weekend"
|
||||
assert spec["color"] == "teal"
|
||||
assert "color" not in spec
|
||||
assert spec["pinned"] is True
|
||||
assert spec["archived"] 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
|
||||
|
||||
|
||||
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 = {
|
||||
"textContent": "Read this later",
|
||||
"annotations": [{"url": "https://example.com"}],
|
||||
"color": "BROWN", # no brown in our palette → nearest (orange)
|
||||
"color": "BROWN",
|
||||
"attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}],
|
||||
}
|
||||
spec = _keep_spec(kn, "Takeout/Keep")
|
||||
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
|
||||
assert spec["attachments"] == [{"file": "Takeout/Keep/img.jpg", "mime": "image/jpeg"}]
|
||||
|
||||
@@ -368,6 +483,8 @@ def test_native_spec_roundtrip_fields():
|
||||
n = {
|
||||
"title": "T",
|
||||
"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",
|
||||
"pinned": True,
|
||||
"archived": False,
|
||||
@@ -381,7 +498,7 @@ def test_native_spec_roundtrip_fields():
|
||||
# _create_imported_note folds it into the body rather than dropping it.
|
||||
assert spec["title"] == "T"
|
||||
assert spec["body"] == "b"
|
||||
assert spec["color"] == "blue"
|
||||
assert "color" not in spec
|
||||
assert spec["pinned"] is True
|
||||
assert spec["trashed"] is False # exports only carry live notes
|
||||
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(None) == []
|
||||
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}]
|
||||
|
||||
@@ -12,13 +12,16 @@ def app():
|
||||
def test_clean_params_whitelists_facet_keys():
|
||||
raw = {
|
||||
"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",
|
||||
"label": ["a"],
|
||||
"has_reminder": True,
|
||||
"junk": 1,
|
||||
"__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(None) == {}
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
"""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"
|
||||
SHOULD_BUILD = Path(__file__).resolve().parent.parent / "packaging" / "should-build.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
|
||||
|
||||
|
||||
# --- the backwards guard against a channel ------------------------------------
|
||||
#
|
||||
# The half of the guard that talks to a release feed, which the `compare` tests above
|
||||
# deliberately do not reach. Hermetic anyway: `curl` is shadowed on PATH by a stub, so
|
||||
# these assert what the guard does with an answer rather than what the forge returns.
|
||||
#
|
||||
# WHY THIS SECTION EXISTS AT ALL. The empty-channel case — a channel that has never
|
||||
# published this artifact — is one the guard is written to PASS, and it says so in a
|
||||
# branch of its own. It did not: `published="$(published_for ...)"` under `set -e`
|
||||
# died on the substitution before that branch could run, because Android's lookup
|
||||
# ended in a `grep` that exits 1 on no match while its three siblings ended in a `sed`
|
||||
# that exits 0. Silent, because everything the pipeline printed went into the capture.
|
||||
# It took the Android lane down on the first merge to `main` (run 4857) and nothing
|
||||
# here would have caught it.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def curl_stub(tmp_path: Path):
|
||||
"""A `curl` on PATH that serves whatever the test says, or 404s.
|
||||
|
||||
Returns a callable: `serve(None)` for a channel with nothing published (the stub
|
||||
exits 22, as real curl does under `-f` on an HTTP error), `serve(body)` to hand
|
||||
back a feed.
|
||||
"""
|
||||
bindir = tmp_path / "stubbin"
|
||||
bindir.mkdir()
|
||||
body = bindir / "body"
|
||||
stub = bindir / "curl"
|
||||
stub.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f'[ -f "{body}" ] || exit 22\n'
|
||||
f'cat "{body}"\n'
|
||||
)
|
||||
stub.chmod(0o755)
|
||||
|
||||
def serve(content: str | None) -> dict[str, str]:
|
||||
if content is None:
|
||||
body.unlink(missing_ok=True)
|
||||
else:
|
||||
body.write_text(content)
|
||||
return {**os.environ, "PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}"}
|
||||
|
||||
return serve
|
||||
|
||||
|
||||
def guard(repo: Path, env: dict[str, str], *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["sh", str(GUARD), *args],
|
||||
cwd=repo, env=env, capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("artifact", ["desktop", "android"])
|
||||
def test_an_empty_channel_passes_the_guard(repo: Path, curl_stub, artifact: str) -> None:
|
||||
"""Nothing published yet is not a backwards move — it is the first publish.
|
||||
|
||||
Failing here would block the very first build to reach a channel, which is exactly
|
||||
what happened to Android on `stable`.
|
||||
"""
|
||||
r = guard(repo, curl_stub(None), artifact, "stable")
|
||||
assert r.returncode == 0, f"exit={r.returncode} stdout={r.stdout!r} stderr={r.stderr!r}"
|
||||
assert "nothing to compare" in r.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("artifact", ["desktop", "android"])
|
||||
def test_an_empty_channel_reports_no_published_version(
|
||||
repo: Path, curl_stub, artifact: str
|
||||
) -> None:
|
||||
"""`published` answers empty rather than failing — `should-build.sh` captures it
|
||||
the same way the guard does, so a non-zero exit strands that caller too."""
|
||||
r = guard(repo, curl_stub(None), "published", artifact, "stable")
|
||||
assert r.returncode == 0, f"exit={r.returncode} stderr={r.stderr!r}"
|
||||
assert r.stdout.strip() == ""
|
||||
|
||||
|
||||
def test_an_empty_channel_builds(repo: Path, curl_stub) -> None:
|
||||
r = subprocess.run(["sh", str(SHOULD_BUILD), "android", "stable"],
|
||||
cwd=repo, env=curl_stub(None), capture_output=True, text=True)
|
||||
assert r.returncode == 0, f"exit={r.returncode} stderr={r.stderr!r}"
|
||||
assert r.stdout.strip() == "true"
|
||||
|
||||
|
||||
def test_a_lower_published_version_passes(repo: Path, curl_stub) -> None:
|
||||
"""The transition this milestone performs: `stable` served the bare 0.2.0 from
|
||||
Cargo.toml, and the new key has to clear it."""
|
||||
env = curl_stub('{"version": "0.2.0", "platforms": {}}')
|
||||
r = guard(repo, env, "desktop", "stable")
|
||||
assert r.returncode == 0, f"stderr={r.stderr!r}"
|
||||
assert "may be published" in r.stdout
|
||||
|
||||
|
||||
def test_a_higher_published_version_fails_the_lane(repo: Path, curl_stub) -> None:
|
||||
"""The unrecoverable direction. A version below what is published leaves every
|
||||
installed client reporting 'up to date' forever, so this fails rather than warns."""
|
||||
env = curl_stub('{"version": "9.9.9", "platforms": {}}')
|
||||
r = guard(repo, env, "desktop", "stable")
|
||||
assert r.returncode != 0
|
||||
assert "GUARD FAILED" in r.stderr
|
||||
|
||||
|
||||
def test_an_equal_android_code_fails_because_android_will_not_install_it(
|
||||
repo: Path, curl_stub
|
||||
) -> None:
|
||||
"""Android hard-fails a non-rising versionCode, so equality is refused there —
|
||||
unlike the desktop, where an unchanged source deriving its own value is ordinary."""
|
||||
code = version(repo, "key", "android")
|
||||
env = curl_stub(f'{{"version_code": {code}, "version_name": "x"}}')
|
||||
r = guard(repo, env, "android", "stable")
|
||||
assert r.returncode != 0
|
||||
assert "EQUALS" in r.stderr
|
||||
Reference in New Issue
Block a user