Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2854a029 | ||
|
|
62338bb0a4 | ||
|
|
1a41373347 | ||
|
|
729d0dadf1 | ||
|
|
23a61365da | ||
|
|
6c0153be1e | ||
|
|
10ea15bef0 | ||
|
|
42e06da576 | ||
|
|
c8318c323a | ||
|
|
cc50812a86 | ||
|
|
1e54b80f15 | ||
|
|
550a34d8e2 | ||
|
|
193dfb9e94 | ||
|
|
8c7553d619 | ||
|
|
d838b27518 | ||
|
|
a69159e562 | ||
|
|
c40916699b | ||
|
|
ef418a8c92 | ||
|
|
fd1e4ae487 | ||
|
|
8a75e5f340 | ||
|
|
d2f9d316cf | ||
|
|
ff6e99eb62 | ||
|
|
ef8aa9340f | ||
|
|
f992439588 | ||
|
|
544cf72735 | ||
|
|
6e524ec616 | ||
|
|
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
|
||||
|
||||
+98
-81
@@ -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
|
||||
@@ -305,42 +323,41 @@ jobs:
|
||||
docker system prune -af || true
|
||||
docker builder prune --keep-storage 5g -f || true
|
||||
|
||||
# Bake the Android client in, on EVERY image build, so :dev, :latest and
|
||||
# :<version> all carry one and a `docker compose pull` delivers a new client
|
||||
# along with the new server.
|
||||
# Bake EVERY client in, on every image build, so a self-hoster gets a working
|
||||
# app for their machine from the server holding their notes — without an
|
||||
# account on this forge, which is private (issue 2091) and is why serving them
|
||||
# from a release page was never an option for anybody but the operator.
|
||||
#
|
||||
# Always the rolling `dev` release — the newest build there is. A versioned
|
||||
# image therefore carries the newest client rather than one pinned to that
|
||||
# version; the two negotiate a sync protocol version before linking, so
|
||||
# "newest" is safe in a way "matching" would not buy anything over.
|
||||
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
|
||||
# the price of the product being complete (rule 23), and the AppImage is not
|
||||
# optional within it: it is the ONLY bundle that can replace itself in place,
|
||||
# so a server without one cannot serve in-app updates to anyone.
|
||||
#
|
||||
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
|
||||
# Fetched by the JOB, not by the Dockerfile: the releases are private, and a
|
||||
# token used inside a build lands in the context or a layer.
|
||||
#
|
||||
# NEVER fails the build. An image with no Android client advertises none and
|
||||
# hides the download — a supported state, and the only one available before
|
||||
# the first Android build has ever published.
|
||||
- name: Fetch the Android client to bake in
|
||||
# NEVER fails the build — see the script. A platform with nothing published
|
||||
# means the server advertises nothing for it and the UI hides that download,
|
||||
# which is a supported state and the only one available before that platform's
|
||||
# first build has ever published.
|
||||
- name: Fetch the clients to bake in
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
mkdir -p client
|
||||
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev"
|
||||
ok=1
|
||||
for f in thoughtsync.apk thoughtsync-android.json; do
|
||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo "Baking in:"
|
||||
cat client/thoughtsync-android.json
|
||||
ls -l client/thoughtsync.apk
|
||||
else
|
||||
# Both or neither. Half a pair is worse than none: the server would
|
||||
# read a sidecar describing an APK that isn't there, or an APK it
|
||||
# cannot state a version for.
|
||||
echo "::warning::No Android client on the dev release — this image ships without one."
|
||||
rm -f client/thoughtsync.apk client/thoughtsync-android.json
|
||||
fi
|
||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
|
||||
# :latest serves stable ones. This read `download/dev` unconditionally
|
||||
# until M314 step 3, on every branch — so every stable server shipped a
|
||||
# dev-channel APK to anyone who downloaded the client from it. Not a
|
||||
# versioning gap; a plain defect, and the reason the channel is chosen here
|
||||
# rather than inside the script: the caller is what knows which image it is
|
||||
# building.
|
||||
case "${{ github.ref_name }}" in
|
||||
main) channel=stable ;;
|
||||
*) channel=dev ;;
|
||||
esac
|
||||
sh packaging/fetch-clients.sh "$channel" client
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
+173
-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,20 @@ 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"
|
||||
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||
# A different value for a different audience: this is the one a person
|
||||
# quotes in a bug report, the key above is the one only a comparator
|
||||
# sees. Exported rather than passed as a flag because the macro that
|
||||
# reads it is in Rust source, not in Tauri's config.
|
||||
THOUGHTSYNC_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
|
||||
export THOUGHTSYNC_DISPLAY_VERSION
|
||||
echo "Baking display version $THOUGHTSYNC_DISPLAY_VERSION"
|
||||
cargo tauri build \
|
||||
--config '{"build":{"beforeBuildCommand":""}}' \
|
||||
--config "{\"version\":\"$version\"}" \
|
||||
@@ -205,37 +250,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 +301,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 +349,20 @@ 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"
|
||||
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||
# A different value for a different audience: this is the one a person
|
||||
# quotes in a bug report, the key above is the one only a comparator
|
||||
# sees. Exported rather than passed as a flag because the macro that
|
||||
# reads it is in Rust source, not in Tauri's config.
|
||||
THOUGHTSYNC_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
|
||||
export THOUGHTSYNC_DISPLAY_VERSION
|
||||
echo "Baking display version $THOUGHTSYNC_DISPLAY_VERSION"
|
||||
updater='{}'
|
||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||
updater='{"bundle":{"createUpdaterArtifacts":true}}'
|
||||
@@ -317,35 +386,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 +433,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 +458,29 @@ 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)"
|
||||
# The version a PERSON reads, published beside the manifest as
|
||||
# `thoughtsync-desktop.json`. The image build reads it to describe the
|
||||
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
|
||||
# anything from its own checkout — which would be a different commit
|
||||
# whenever the desktop did not rebuild.
|
||||
display="$(sh packaging/version.sh display desktop)"
|
||||
# Both channels are rolling: the manifest lands on the same release that
|
||||
# holds the bundles, and the previous build's bundles are dropped once it
|
||||
# points at this one. Nothing can reach them, and they are ~100 MB a push.
|
||||
#
|
||||
# No tag arm any more. A `v*` tag does not reach this workflow at all — it
|
||||
# triggers release.yml, which writes a changelog and builds nothing.
|
||||
case "${GITHUB_REF_NAME}" in
|
||||
main) 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" DISPLAY_VERSION="$display" \
|
||||
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
|
||||
Generated
+67
@@ -1286,6 +1286,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1405,6 +1415,24 @@ version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "global-hotkey"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"keyboard-types",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"windows-sys 0.59.0",
|
||||
"x11rb",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
@@ -3947,6 +3975,21 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-global-shortcut"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b"
|
||||
dependencies = [
|
||||
"global-hotkey",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.9.0"
|
||||
@@ -4196,6 +4239,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-updater",
|
||||
"thoughtsync-core",
|
||||
@@ -5577,6 +5621,23 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -5587,6 +5648,12 @@ dependencies = [
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xkeysym"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
+13
-7
@@ -24,17 +24,23 @@ COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
|
||||
COPY alembic.ini .
|
||||
COPY alembic/ alembic/
|
||||
|
||||
# The Android client this server hands out. CI fetches the newest published build
|
||||
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
|
||||
# :latest and :<version> alike — ships a client, and a `docker compose pull`
|
||||
# delivers a new one with no file copying by hand.
|
||||
# The clients this server hands out — the APK and all four desktop bundles. CI
|
||||
# fetches the newest published build of each into ./client immediately before this
|
||||
# runs (packaging/fetch-clients.sh), so both image tags ship a full set and a
|
||||
# `docker compose pull` delivers new ones with no file copying by hand.
|
||||
#
|
||||
# Fetched by the JOB rather than here on purpose: the release is private, and a
|
||||
# ~104 MB of this image is that set, almost all of it the AppImage.
|
||||
#
|
||||
# Fetched by the JOB rather than here on purpose: the releases are private, and a
|
||||
# token used inside a build ends up in the build context or a layer.
|
||||
#
|
||||
# LAST of the COPYs, deliberately: this directory changes on every build, so
|
||||
# putting it above the `pip install` layer would invalidate that layer every time.
|
||||
#
|
||||
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
|
||||
# that step never ran. An image with no APK is a supported state — the server
|
||||
# advertises nothing and the web UI hides the download (client_dist.py).
|
||||
# that step never ran. An image with no clients — or with some and not others — is
|
||||
# a supported state: the server advertises what it has and the web UI hides the
|
||||
# rest (client_dist.py).
|
||||
COPY client/ src/thoughtsync/client/
|
||||
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
@@ -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
|
||||
@@ -96,15 +99,53 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ThoughtSync"
|
||||
android:usesCleartextTraffic="true">
|
||||
<!--
|
||||
launchMode="singleTop" exists for the SHARE filters below.
|
||||
|
||||
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
|
||||
intent, so onNewIntent already worked for that one. A share intent is
|
||||
built by the OTHER app — Chrome, a reader, the text-selection toolbar —
|
||||
and nothing here can add a flag to it. Without singleTop declared on the
|
||||
activity itself, every share while the app is running would stack a
|
||||
second MainActivity on top of the first: a second view model, a second
|
||||
board, and a back press that lands on a stale copy of the same app.
|
||||
-->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.ThoughtSync">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!--
|
||||
Capture without opening the app first: Share → ThoughtSync from
|
||||
anywhere, and the selection toolbar in any text field.
|
||||
|
||||
text/plain ONLY, and image/* deliberately absent. Nothing in this
|
||||
app can create an attachment — the core has `delete_attachment` and
|
||||
no counterpart, and the FFI exposes neither. Claiming images in the
|
||||
share sheet would put this app in front of people for a job it
|
||||
cannot do and fail after they had chosen it.
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
<!--
|
||||
The label is what appears in the text-selection menu beside Copy and
|
||||
Share, where "ThoughtSync" would say who rather than what.
|
||||
-->
|
||||
<intent-filter android:label="@string/capture_process_text">
|
||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* The `versionName` of the INSTALLED package, or null when it cannot be read.
|
||||
*
|
||||
* From the package manager rather than from `BuildConfig`: this reports what is
|
||||
* actually on the phone, which is the question both callers are asking — a bug
|
||||
* report reading the foot of Sync, and a server log reading the client header. It
|
||||
* also needs no `buildFeatures.buildConfig`, which this module does not enable.
|
||||
*
|
||||
* Returns null rather than a fallback string, because the two callers want
|
||||
* different ones: the UI wants a localized "unknown" from string resources, the
|
||||
* client header wants the literal the core recognizes. Note 3127 §5 governs both —
|
||||
* with no version tags, the artifact's self-report is the only answer to "which
|
||||
* build is this?", so a missing name must read as missing and never as a plausible
|
||||
* default that nothing can contradict.
|
||||
*/
|
||||
fun Context.installedVersionName(): String? =
|
||||
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
|
||||
@@ -25,14 +25,16 @@ 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
|
||||
import com.fabledsword.thoughtsync.ui.SyncScreen
|
||||
import com.fabledsword.thoughtsync.ui.SyncState
|
||||
import com.fabledsword.thoughtsync.ui.SyncViewModel
|
||||
import com.fabledsword.thoughtsync.ui.TagsScreen
|
||||
import com.fabledsword.thoughtsync.ui.TagsViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
||||
import com.fabledsword.thoughtsync.ui.UpdateViewModel
|
||||
import com.fabledsword.thoughtsync.ui.olderThan
|
||||
@@ -51,12 +53,24 @@ class MainActivity : ComponentActivity() {
|
||||
*/
|
||||
private val requestedNote = mutableStateOf<String?>(null)
|
||||
|
||||
/**
|
||||
* Text shared into the app from elsewhere, waiting to become a note.
|
||||
*
|
||||
* Same shape and same reason as [requestedNote]: a share that arrives while
|
||||
* the app is already running lands in [onNewIntent], long after the
|
||||
* composition was built, so a piece of state it is already reading is the only
|
||||
* way in. The activity is `singleTop` in the manifest precisely so that this
|
||||
* path exists for an intent another app built.
|
||||
*/
|
||||
private val sharedText = mutableStateOf<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val app = application as ThoughtSyncApplication
|
||||
requestedNote.value = takeRequestedNote(intent)
|
||||
sharedText.value = takeSharedText(intent)
|
||||
|
||||
setContent {
|
||||
ThoughtSyncTheme {
|
||||
@@ -67,7 +81,7 @@ class MainActivity : ComponentActivity() {
|
||||
// than render an empty board that looks like data loss.
|
||||
StoreUnavailableScreen(reason = app.openFailure)
|
||||
} else {
|
||||
App(core, requestedNote)
|
||||
App(core, requestedNote, sharedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +91,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
requestedNote.value = takeRequestedNote(intent)
|
||||
sharedText.value = takeSharedText(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,10 +107,58 @@ class MainActivity : ComponentActivity() {
|
||||
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the text a share or a text selection brought in, and CONSUME it.
|
||||
*
|
||||
* Consumed for the same reason [takeRequestedNote] is: the activity keeps the
|
||||
* intent it was launched with, so without removing the extras a rotation would
|
||||
* replay the share and mint the same note again, with nothing on screen to
|
||||
* explain where the duplicates were coming from.
|
||||
*/
|
||||
private fun takeSharedText(intent: Intent?): String? {
|
||||
val shared =
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SEND -> intent.takeSendText()
|
||||
Intent.ACTION_PROCESS_TEXT -> intent.takeProcessText()
|
||||
else -> null
|
||||
}
|
||||
return shared?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared text, with a subject line above it when the sender gave one.
|
||||
*
|
||||
* Sharing a page from a browser sends EXTRA_SUBJECT as the page title and
|
||||
* EXTRA_TEXT as the URL. Keeping both makes the note read as its title, because
|
||||
* the core names a note by its first line — so this is not decoration, it is what
|
||||
* turns a board of identical-looking links into a board you can scan.
|
||||
*
|
||||
* `distinct` because plenty of apps put the same string in both, and a note that
|
||||
* says the URL twice is worse than one that says it once.
|
||||
*/
|
||||
private fun Intent.takeSendText(): String? {
|
||||
val body = getStringExtra(Intent.EXTRA_TEXT)
|
||||
val subject = getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
removeExtra(Intent.EXTRA_TEXT)
|
||||
removeExtra(Intent.EXTRA_SUBJECT)
|
||||
return listOfNotNull(subject, body)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.joinToString("\n")
|
||||
}
|
||||
|
||||
/** The selection from another app's text field, via the selection toolbar. */
|
||||
private fun Intent.takeProcessText(): String? {
|
||||
val text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
|
||||
removeExtra(Intent.EXTRA_PROCESS_TEXT)
|
||||
return text
|
||||
}
|
||||
|
||||
/** Which screen is up. Exactly one at a time. */
|
||||
private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
|
||||
|
||||
/**
|
||||
* The whole app, once the store is open.
|
||||
@@ -104,7 +167,7 @@ private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
* both cover the display completely, so keeping the board's two-column grid
|
||||
* measuring and recomposing underneath one would be pure waste.
|
||||
*
|
||||
* Still no navigation library. Three destinations, each entered from exactly one
|
||||
* Still no navigation library. Four destinations, each entered from exactly one
|
||||
* place and left by back — a nav graph would be ceremony around an enum, and the
|
||||
* state that actually matters (which note is open, whether this device is linked)
|
||||
* already lives in view models.
|
||||
@@ -113,6 +176,7 @@ private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
private fun App(
|
||||
core: ThoughtSync,
|
||||
requestedNote: MutableState<String?>,
|
||||
sharedText: MutableState<String?>,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val board: BoardViewModel =
|
||||
@@ -135,32 +199,51 @@ private fun App(
|
||||
}
|
||||
}
|
||||
|
||||
// Cleared the same way and for the same reason: without it every later
|
||||
// recomposition would capture the shared text again as a new note.
|
||||
LaunchedEffect(sharedText.value) {
|
||||
sharedText.value?.let {
|
||||
board.captureShared(it)
|
||||
sharedText.value = null
|
||||
}
|
||||
}
|
||||
|
||||
ReminderAlarms(core)
|
||||
// A pull can rewrite every note the board is holding, so a sync that changed
|
||||
// anything tells it to reload. Wired here, at the one place that owns both.
|
||||
val sync: SyncViewModel =
|
||||
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
|
||||
|
||||
// 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) }
|
||||
var showingTags by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Tag writes reach the board two ways at once: the drawer lists tags, and the
|
||||
// board may be LOOKING at one that a delete or a merge just removed. Both are
|
||||
// `refreshLabels`, which also leaves a lens whose tag stopped existing.
|
||||
val tags: TagsViewModel =
|
||||
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels))
|
||||
|
||||
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||
val settings = remember(context) { SyncSettings(context) }
|
||||
var automatic by remember { mutableStateOf(settings.automatic) }
|
||||
|
||||
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
|
||||
AutomaticUpdate(linked = sync.state.linked, onCheck = update::checkInBackground)
|
||||
|
||||
val editing = board.state.editing
|
||||
val screen =
|
||||
when {
|
||||
showingSync -> Screen.SYNC
|
||||
// Above the editor: tags are reached only from the board's drawer, so
|
||||
// there is never an open note underneath one to go back to.
|
||||
showingTags -> Screen.TAGS
|
||||
editing != null -> Screen.EDITOR
|
||||
else -> Screen.BOARD
|
||||
}
|
||||
@@ -188,10 +271,23 @@ private fun App(
|
||||
onInstallOutcome = update::consumeInstallOutcome,
|
||||
)
|
||||
|
||||
Screen.TAGS ->
|
||||
TagsScreen(
|
||||
state = tags.state,
|
||||
onClose = { showingTags = false },
|
||||
onCreate = tags::create,
|
||||
onRename = tags::rename,
|
||||
onColour = tags::setColour,
|
||||
onDelete = tags::remove,
|
||||
onMerge = tags::merge,
|
||||
onDismissError = tags::dismissError,
|
||||
)
|
||||
|
||||
Screen.EDITOR ->
|
||||
NoteEditorScreen(
|
||||
// Non-null by construction: `screen` is EDITOR only when it is.
|
||||
note = requireNotNull(editing) { "the editor screen needs a note" },
|
||||
sessionKey = board.state.editingSession,
|
||||
labels = board.state.labels,
|
||||
saving = board.state.saving,
|
||||
error = board.state.error,
|
||||
@@ -217,27 +313,37 @@ private fun App(
|
||||
onDismissError = sync::dismissSyncError,
|
||||
),
|
||||
onOpenSync = { showingSync = true },
|
||||
onManageTags = { showingTags = 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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sync screen has no back handler of its own, so one lives here. The
|
||||
// editor keeps its own, because it has to save the open note before leaving.
|
||||
BackHandler(enabled = showingSync) { showingSync = false }
|
||||
BackHandler(enabled = showingTags) { showingTags = false }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,6 +388,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.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.fabledsword.thoughtsync
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.thoughtsync.core.setClientAgent
|
||||
|
||||
/**
|
||||
* Opens the shared Rust core once, for the process lifetime.
|
||||
@@ -30,6 +31,14 @@ class ThoughtSyncApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Introduce this app to any server it links to, before anything can sync.
|
||||
// The core cannot name us — the same crate is compiled into the desktop app,
|
||||
// and it used to announce every phone as `thoughtsync-desktop` carrying the
|
||||
// core crate's own version. "unknown" rather than a guess when the package
|
||||
// manager will not say (note 3127 §5).
|
||||
setClientAgent("thoughtsync-android", installedVersionName() ?: "unknown")
|
||||
|
||||
try {
|
||||
val handle = ThoughtSync(filesDir.absolutePath)
|
||||
core = handle
|
||||
|
||||
@@ -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
|
||||
@@ -23,6 +26,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -37,6 +41,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 +53,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
|
||||
@@ -62,12 +75,55 @@ fun BoardScreen(
|
||||
onOpenNote: (Note) -> Unit,
|
||||
sync: BoardSync,
|
||||
onOpenSync: () -> Unit,
|
||||
onManageTags: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||
onNoteAction: (Note, EditorAction) -> Unit,
|
||||
update: BoardUpdate?,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbars = remember { SnackbarHostState() }
|
||||
|
||||
// Held HERE rather than on the card. A card lives in a lazy grid and is disposed
|
||||
// the moment it scrolls out of view, which would take its dialog down with it —
|
||||
// and the board can scroll under an open dialog.
|
||||
var confirmingDelete by remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
// Resolved in composition, not inside the coroutine: `stringResource` is a
|
||||
// composable read and cannot be called from a suspend block.
|
||||
val trashedMessage = stringResource(R.string.board_trashed)
|
||||
val undoLabel = stringResource(R.string.board_undo)
|
||||
|
||||
// Trash gets an UNDO rather than a confirmation, and the two are not
|
||||
// interchangeable. A long press is a gesture you can make by accident — resting a
|
||||
// thumb while reading is enough — so the mistake worth designing for is the one
|
||||
// nobody meant to make, and a dialog only helps someone who is paying attention
|
||||
// in the moment they were not. Trash is already recoverable; the snackbar just
|
||||
// says so where it happened, instead of leaving you to find the Trash view and
|
||||
// work out which note went missing.
|
||||
//
|
||||
// Delete forever keeps its dialog. That one does not undo.
|
||||
val onCardAction: (Note, EditorAction) -> Unit = { note, action ->
|
||||
onNoteAction(note, action)
|
||||
if (action == EditorAction.Trash) {
|
||||
scope.launch {
|
||||
val outcome =
|
||||
snackbars.showSnackbar(
|
||||
message = trashedMessage,
|
||||
actionLabel = undoLabel,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
// `note` is the pre-trash copy and deliberately so: Restore only needs
|
||||
// its id, and the id is the one thing trashing does not change.
|
||||
if (outcome == SnackbarResult.ActionPerformed) {
|
||||
onNoteAction(note, EditorAction.Restore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -84,10 +140,30 @@ fun BoardScreen(
|
||||
onOpenSync()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onManageTags = {
|
||||
onManageTags()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
// The IME, added to what the Scaffold already insets for. `enableEdgeToEdge`
|
||||
// makes the manifest's `adjustResize` a no-op on API 30+, so nothing resizes
|
||||
// for the keyboard unless the app asks — and `ScaffoldDefaults.contentWindowInsets`
|
||||
// is systemBars, which the IME is not part of. The Scaffold positions the FAB
|
||||
// AND the snackbar host from this value, so without it both sit behind the
|
||||
// keyboard whenever the search field has focus. That is not theoretical: the
|
||||
// undo on a trashed search hit is exactly the control you cannot reach.
|
||||
//
|
||||
// `union` rather than `add` — the two are the same edge, not two stacked ones.
|
||||
// Adding them would inset by the navigation bar a second time underneath a
|
||||
// keyboard that already covers it.
|
||||
//
|
||||
// One owner for the edge, as with the search bar's missing statusBarsPadding:
|
||||
// set here, the content Column gets it through `padding` and must not repeat it.
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||
snackbarHost = { SnackbarHost(snackbars) },
|
||||
floatingActionButton = {
|
||||
// The + is the ONLY way in, by design: one obvious target rather
|
||||
// than a capture bar and a button competing for the same job.
|
||||
@@ -117,6 +193,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 +227,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 +247,16 @@ fun BoardScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmingDelete?.let { note ->
|
||||
ConfirmDeleteDialog(
|
||||
onConfirm = {
|
||||
confirmingDelete = null
|
||||
onNoteAction(note, EditorAction.DeleteForever)
|
||||
},
|
||||
onDismiss = { confirmingDelete = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +285,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.
|
||||
@@ -250,6 +373,7 @@ private fun NavigationDrawer(
|
||||
syncSummary: String?,
|
||||
onOpen: (Destination) -> Unit,
|
||||
onOpenSync: () -> Unit,
|
||||
onManageTags: () -> Unit,
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
@@ -263,18 +387,36 @@ private fun NavigationDrawer(
|
||||
DrawerRow(destination, current, onOpen)
|
||||
}
|
||||
|
||||
if (labels.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
// The header renders even with no tags, unlike the rows below it: the
|
||||
// manage screen is where you go to MAKE the first one, and hiding the
|
||||
// way in until one exists would be a door that appears only once you
|
||||
// are already inside. It is an action ON the section rather than a row
|
||||
// in it, so it cannot be mistaken for one more lens.
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.nav_labels),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
labels.forEach { label ->
|
||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||
IconButton(onClick = onManageTags) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = stringResource(R.string.tags_manage),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
labels.forEach { label ->
|
||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
||||
@@ -323,6 +465,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 +481,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()
|
||||
@@ -113,7 +123,7 @@ class BoardViewModel(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
loadLabels()
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
fun open(destination: Destination) {
|
||||
@@ -152,13 +162,34 @@ class BoardViewModel(
|
||||
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
|
||||
}
|
||||
|
||||
private fun loadLabels() {
|
||||
/**
|
||||
* Reload the drawer's tags, and leave a lens whose tag no longer exists.
|
||||
*
|
||||
* Public because the Tags screen owns operations this board cannot see: a
|
||||
* delete or a merge removes a tag, and the board may be LOOKING at that tag —
|
||||
* `Destination.WithLabel` holds an id, and a query for a deleted one returns
|
||||
* nothing forever. Without the fallback, tidying up tags could strand the board
|
||||
* on a permanently empty lens whose only escape is the drawer.
|
||||
*
|
||||
* A rename needs no fallback: the id survives, and re-listing gives the drawer
|
||||
* the new name. A rename that MERGED is a delete of one of the two, which this
|
||||
* catches by id like any other.
|
||||
*/
|
||||
fun refreshLabels() {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.onSuccess { state = state.copy(labels = it) }
|
||||
.onSuccess { labels ->
|
||||
state = state.copy(labels = labels)
|
||||
val lens = state.destination
|
||||
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
|
||||
open(Destination.Notes)
|
||||
}
|
||||
}
|
||||
// A drawer that cannot list labels is a degraded drawer, not a
|
||||
// broken board — the notes are still there. Failing quietly here
|
||||
// beats an error banner over working content.
|
||||
// beats an error banner over working content. The lens is left
|
||||
// alone in this case on purpose: "I could not read the tags" is not
|
||||
// evidence that this one is gone.
|
||||
.onFailure { state = state.copy(labels = emptyList()) }
|
||||
}
|
||||
}
|
||||
@@ -188,44 +219,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 +231,137 @@ 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture text shared into the app from somewhere else, and open it.
|
||||
*
|
||||
* The note is CREATED here rather than opened as a pre-filled draft, and that
|
||||
* is the whole design of this path. The editor only flushes when its text
|
||||
* differs from the note it was handed (`NoteEditorScreen`'s `flush`), so a
|
||||
* draft arriving already full of the shared text is a draft with nothing to
|
||||
* save — share a link, press back without typing, and it would be gone. A
|
||||
* share has already said "keep this"; making the row first is what honours it.
|
||||
*
|
||||
* Opening the editor afterwards is then free of that risk: the note exists,
|
||||
* back leaves it alone, and adding a line of context is optional rather than
|
||||
* load-bearing.
|
||||
*/
|
||||
fun captureShared(text: String) {
|
||||
val content = text.trim()
|
||||
if (content.isEmpty()) return
|
||||
// A share is a new sitting even if the editor was already open on
|
||||
// something, so the field must be re-keyed onto what arrives. `createFrom
|
||||
// Draft` deliberately does not bump this — it is written for the autosave
|
||||
// case, where re-keying mid-typing would be the bug.
|
||||
draftDismissed = false
|
||||
state = state.copy(editingSession = state.editingSession + 1)
|
||||
createFromDraft(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set when a draft's editor closes, so a create still in flight does not reopen
|
||||
* it. The editor flushes its text and then closes, and the flush is a coroutine —
|
||||
* without this the note would be created, the screen would close, and the create
|
||||
* would finish and put the screen back.
|
||||
*/
|
||||
private var draftDismissed = false
|
||||
|
||||
/**
|
||||
* The editor's actions, for a note that has no row yet.
|
||||
*
|
||||
* Everything a toolbar button does needs an id to act on, so the first action
|
||||
* that needs one creates the note and replays itself against the real thing.
|
||||
*/
|
||||
private fun onDraftAction(
|
||||
draft: Note,
|
||||
action: EditorAction,
|
||||
) {
|
||||
when (action) {
|
||||
// Nothing exists, so leaving leaves nothing behind — which is what makes
|
||||
// tapping + and changing your mind free. Text typed before this point has
|
||||
// already gone to createFromDraft via the editor's autosave or its flush.
|
||||
EditorAction.Close, EditorAction.Trash -> {
|
||||
draftDismissed = true
|
||||
state = state.copy(editing = null)
|
||||
}
|
||||
EditorAction.DismissError -> dismissError()
|
||||
is EditorAction.SaveText -> createFromDraft(action.body)
|
||||
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
|
||||
// a note. With autosave at a second, "typed something" is true by the time
|
||||
// anyone reaches the toolbar; before that there is nothing to attribute.
|
||||
else -> createFromDraft(draft.body) { created -> onEditorAction(created, action) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a draft into a row, and keep the editor on it.
|
||||
*
|
||||
* Adopting the created note is what lets a session of autosaves stay one note:
|
||||
* the second save sees a real id and updates rather than creating again.
|
||||
*/
|
||||
private fun createFromDraft(
|
||||
content: String,
|
||||
allowEmpty: Boolean = false,
|
||||
then: (Note) -> Unit = {},
|
||||
) {
|
||||
val cleanContent = content.trim()
|
||||
// A blank draft is not a note. Ignored rather than rejected: tapping + and
|
||||
// walking away is a slip, not a mistake worth interrupting someone over.
|
||||
if (cleanContent.isEmpty() && !allowEmpty) return
|
||||
viewModelScope.launch {
|
||||
state = state.copy(saving = true)
|
||||
state =
|
||||
try {
|
||||
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
|
||||
// Prepend rather than reload: the new note belongs at the top of
|
||||
// the board, and a full re-query would cost a round trip to tell
|
||||
// us what we already know. Skipped when the board is not showing
|
||||
// plain notes — a note created while looking at Trash does not
|
||||
// belong in that list.
|
||||
val notes =
|
||||
if (state.destination == Destination.Notes && !state.searching) {
|
||||
listOf(created) + state.notes
|
||||
} else {
|
||||
state.notes
|
||||
}
|
||||
withContext(Dispatchers.IO) { onRemindersChanged() }
|
||||
// editingSession is NOT bumped: this is the same sitting, and the
|
||||
// editor's field must not be re-keyed underneath the typing.
|
||||
state.copy(
|
||||
notes = notes,
|
||||
editing = if (draftDismissed) state.editing else created,
|
||||
saving = false,
|
||||
error = null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
|
||||
}
|
||||
if (!draftDismissed) state.editing?.let(then)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,18 +386,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 +423,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 ->
|
||||
@@ -327,7 +434,7 @@ class BoardViewModel(
|
||||
}
|
||||
// The drawer lists labels with their note counts, and both
|
||||
// just changed.
|
||||
loadLabels()
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
|
||||
@@ -351,9 +458,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.
|
||||
@@ -364,9 +472,13 @@ class BoardViewModel(
|
||||
* correct-until-a-moment-ago content, and flashing it empty would be a worse
|
||||
* lie than showing it one frame stale.
|
||||
*
|
||||
* Search results are left alone — they are the answer to a query, not a live
|
||||
* view, and re-running the board query underneath them would replace the hits
|
||||
* with the whole board.
|
||||
* While a search is running the QUERY is re-run rather than the board's
|
||||
* destination — running `load` here would replace the hits with the whole
|
||||
* board, which is why this branch exists at all. It used to keep the existing
|
||||
* list instead, and that was right for a note whose place in the pile changed
|
||||
* and wrong for one that left it: trashing a hit left the card sitting there,
|
||||
* with a snackbar saying it was gone, until the query happened to re-run
|
||||
* (#3111). Re-asking is still the answer to the query, just a current one.
|
||||
*/
|
||||
private fun mutate(
|
||||
closeEditor: Boolean = false,
|
||||
@@ -378,10 +490,8 @@ class BoardViewModel(
|
||||
try {
|
||||
val updated = withContext(Dispatchers.IO) { block(core) }
|
||||
val notes =
|
||||
if (state.searching) {
|
||||
state.notes
|
||||
} else {
|
||||
withContext(Dispatchers.IO) { load(state.destination) }
|
||||
withContext(Dispatchers.IO) {
|
||||
if (state.searching) core.searchNotes(state.query) else load(state.destination)
|
||||
}
|
||||
// On IO, not here: re-deriving the alarm reads every note
|
||||
// that carries a reminder, and this line runs on the main
|
||||
@@ -389,7 +499,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 +517,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 +558,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 +573,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
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.core.LinkPreview
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
|
||||
private val PREVIEW_RADIUS = 8.dp
|
||||
|
||||
/**
|
||||
* A URL that is the WHOLE body, whitespace either side allowed.
|
||||
*
|
||||
* Mirrors `LONE_URL_RE` in `NoteCard.vue` deliberately — the two surfaces have to
|
||||
* agree on what counts as "this note is a link", or the same note reads as a card
|
||||
* on one and a paragraph on the other. Someone pasting a link rarely trims it,
|
||||
* which is why the surrounding whitespace is tolerated rather than rejected.
|
||||
*/
|
||||
private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
|
||||
|
||||
/**
|
||||
* The preview for a note that is nothing but a URL, or null.
|
||||
*
|
||||
* Null covers three different situations that all render the same way — the body
|
||||
* is not a lone URL, the server has not unfurled it yet, or it never could. The
|
||||
* card falls back to showing the URL as text in every one of them, so it is never
|
||||
* blank and the link is never unreachable.
|
||||
*
|
||||
* A note written on the phone and not yet synced is permanently in the middle
|
||||
* case: the unfurl happens server-side (`unfurl_queue.py`) and arrives on a later
|
||||
* pull. That is the honest behaviour and it has to look deliberate, which showing
|
||||
* the URL does.
|
||||
*/
|
||||
fun loneUrlPreview(note: Note): LinkPreview? {
|
||||
if (!LONE_URL.matches(note.body)) return null
|
||||
val url = note.body.trim()
|
||||
return note.previews.firstOrNull { it.url == url }
|
||||
}
|
||||
|
||||
/** True when the body is a lone URL, whether or not a preview has arrived for it. */
|
||||
fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body)
|
||||
|
||||
/**
|
||||
* A fetched link preview, in one of two sizes.
|
||||
*
|
||||
* [compact] is a single row — one line of title and the site — for a URL mentioned
|
||||
* *inside* a note that has its own words. The note is the thing; the link is a
|
||||
* footnote to it. Full size is for a note that IS a URL, where the link is the
|
||||
* note and a compact strip would be a card with nothing on it.
|
||||
*
|
||||
* No image, unlike the web's `LinkPreview.vue`. `image_url` is a REMOTE
|
||||
* third-party address, so drawing it would have this app fetch from whatever host
|
||||
* a link happens to point at — on a phone, on possibly metered data, and as the
|
||||
* first image loading anywhere in this client. That is a decision about privacy
|
||||
* and data use rather than a rendering detail, so the text card ships and the
|
||||
* image is left to be asked for (Scribe #3307).
|
||||
*/
|
||||
@Composable
|
||||
fun LinkPreviewCard(
|
||||
preview: LinkPreview,
|
||||
compact: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Every element of the Modifier chain stays on ONE line, which is why the shape
|
||||
// and the two paddings are named first. `standard:chain-method-continuation`
|
||||
// wants a `.` that follows a MULTILINE element glued to its closing paren —
|
||||
// `).padding(…)` — which is unreadable, so the multiline element is avoided
|
||||
// instead (Scribe #3110).
|
||||
val shape = RoundedCornerShape(PREVIEW_RADIUS)
|
||||
val padH = if (compact) 8.dp else 10.dp
|
||||
val padV = if (compact) 6.dp else 8.dp
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||
.padding(horizontal = padH, vertical = padV),
|
||||
) {
|
||||
preview.siteName?.takeIf { it.isNotBlank() }?.let { site ->
|
||||
Text(
|
||||
text = site.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
// The URL stands in for a missing title so the row always says SOMETHING
|
||||
// about where it goes.
|
||||
text = preview.title?.takeIf { it.isNotBlank() } ?: preview.url,
|
||||
style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// First thing to go when there is no room — the compact row is a footnote and
|
||||
// a description would make it the loudest part of the card.
|
||||
if (!compact) {
|
||||
preview.description?.takeIf { it.isNotBlank() }?.let { body ->
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,373 @@ 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))
|
||||
}
|
||||
|
||||
// A note that is NOTHING but a URL renders as its preview and nothing
|
||||
// else — printing the raw address under a card that already says where it
|
||||
// goes is saying the same thing twice, badly. Until the unfurl lands, or
|
||||
// if it never does, `preview` is null and the body falls through to
|
||||
// NoteBody, which shows the URL. Never a blank card.
|
||||
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
|
||||
|
||||
// Body then checklist, in order — a note can carry both (M13 step 2). The
|
||||
// first line of the body IS the note's name, at the same weight as the rest of
|
||||
// it (M13 steps 3 and 4).
|
||||
if (lonePreview != null) {
|
||||
LinkPreviewCard(preview = lonePreview, compact = false)
|
||||
} else if (note.body.isNotBlank()) {
|
||||
NoteBody(note = note, onToggleItem = onToggleItem)
|
||||
}
|
||||
|
||||
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
|
||||
// under the note's own words rather than stacked on top of them. Putting
|
||||
// them above would set a stranger's headline where the note's first line
|
||||
// should be — the web learned that in M13 and moved them down.
|
||||
if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
note.previews.forEach { preview ->
|
||||
LinkPreviewCard(preview = preview, compact = true)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// A note with nothing in it still has to occupy the board legibly — otherwise
|
||||
// it reads as a rendering bug.
|
||||
if (note.body.isBlank() && note.previews.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.board_empty_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
note.remindAt?.let { at ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReminderChip(instant = at, recurrence = note.recurrence)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +388,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 +440,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,
|
||||
)
|
||||
|
||||
@@ -31,12 +31,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||
import com.fabledsword.thoughtsync.installedVersionName
|
||||
|
||||
/**
|
||||
* Opt-in server pairing.
|
||||
@@ -120,10 +122,38 @@ fun SyncScreen(
|
||||
onDismissRevokeNotice = onDismissRevokeNotice,
|
||||
)
|
||||
}
|
||||
|
||||
BuildLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
|
||||
* bottom of its rail (#3181).
|
||||
*
|
||||
* Note 3127 §5 is why it is here at all. With version tags gone, an artifact's own
|
||||
* self-report is the only answer to "which build is this?" — so it renders
|
||||
* "unknown" rather than nothing when the name is absent, because a blank line looks
|
||||
* like a layout bug and a plausible default cannot be caught by anything.
|
||||
*
|
||||
* The read itself is `installedVersionName()`, shared with the client header the
|
||||
* app sends its server: one answer to "which build is on this phone", so the line
|
||||
* a person quotes in a bug report and the line in the server's log cannot disagree.
|
||||
*/
|
||||
@Composable
|
||||
private fun BuildLine() {
|
||||
val context = LocalContext.current
|
||||
val unknown = stringResource(R.string.build_unknown)
|
||||
val version = remember(context) { context.installedVersionName() ?: unknown }
|
||||
Text(
|
||||
text = version,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ───────────────────────────────── linked ─────────────────────────────────
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
|
||||
/** The swatch shown beside a tag, and tapped to change its colour. */
|
||||
private val SWATCH = 22.dp
|
||||
|
||||
/** What the row's overflow menu is currently asking about. */
|
||||
private sealed interface TagDialog {
|
||||
data class Rename(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
/** A rename whose new name another tag already holds — see [RenameDialog]. */
|
||||
data class ConfirmMerge(
|
||||
val tag: Label,
|
||||
val into: Label,
|
||||
val name: String,
|
||||
) : TagDialog
|
||||
|
||||
data class Merge(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
data class Delete(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
data class Colour(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag management: list, create, rename, recolour, delete, merge.
|
||||
*
|
||||
* A destination you go to, not a modal. The web's `LabelsModal.vue` is a modal
|
||||
* because a desktop has room to float one over the board; on a phone this is a
|
||||
* place you visit to tidy up, and a full screen is what that is.
|
||||
*
|
||||
* It is also, since the per-note colour picker was removed, the ONLY colour
|
||||
* control in the product. That is why the swatch is a first-class tap target on
|
||||
* every row rather than something behind the overflow menu.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TagsScreen(
|
||||
state: TagsState,
|
||||
onClose: () -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
onRename: (String, String) -> Unit,
|
||||
onColour: (String, String) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onMerge: (String, String) -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
var dialog by remember { mutableStateOf<TagDialog?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.tags_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.tags_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding(),
|
||||
) {
|
||||
// An indeterminate bar rather than blocking the list: a tag write is a
|
||||
// local SQLite call and usually finishes before this is seen at all.
|
||||
if (state.busy) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
// The same banner the board and the editor use. A third way of saying
|
||||
// "that did not work" would be a third thing to keep consistent.
|
||||
state.error?.let { message ->
|
||||
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||
}
|
||||
|
||||
NewTagField(
|
||||
enabled = !state.busy,
|
||||
onCreate = onCreate,
|
||||
)
|
||||
|
||||
if (!state.loading && state.tags.isEmpty()) {
|
||||
EmptyTags()
|
||||
}
|
||||
|
||||
// weight, NOT fillMaxSize: this has siblings above it, and filling the
|
||||
// whole height would measure the list against space the field and the
|
||||
// banner have already taken — pushing the end of the list off-screen.
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(state.tags, key = { it.id }) { tag ->
|
||||
TagRow(
|
||||
tag = tag,
|
||||
enabled = !state.busy,
|
||||
onColour = { dialog = TagDialog.Colour(tag) },
|
||||
onRename = { dialog = TagDialog.Rename(tag) },
|
||||
onMerge = { dialog = TagDialog.Merge(tag) },
|
||||
onDelete = { dialog = TagDialog.Delete(tag) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val open = dialog) {
|
||||
null -> Unit
|
||||
|
||||
is TagDialog.Rename ->
|
||||
RenameDialog(
|
||||
tag = open.tag,
|
||||
others = state.tags,
|
||||
onDismiss = { dialog = null },
|
||||
onRename = { name ->
|
||||
dialog = null
|
||||
onRename(open.tag.id, name)
|
||||
},
|
||||
// Renaming onto a name another tag holds MERGES the two, and that
|
||||
// cannot be undone by repeating it, so the confirmation replaces
|
||||
// this dialog rather than the rename just happening.
|
||||
onWouldMerge = { into, name -> dialog = TagDialog.ConfirmMerge(open.tag, into, name) },
|
||||
)
|
||||
|
||||
is TagDialog.ConfirmMerge ->
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.tags_rename_merges_title, hash(open.into.name)),
|
||||
body = stringResource(R.string.tags_rename_merges_body, hash(open.into.name)),
|
||||
confirm = stringResource(R.string.tags_rename_merges_confirm),
|
||||
onDismiss = { dialog = null },
|
||||
onConfirm = {
|
||||
dialog = null
|
||||
onRename(open.tag.id, open.name)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Merge ->
|
||||
MergeDialog(
|
||||
tag = open.tag,
|
||||
others = state.tags.filter { it.id != open.tag.id },
|
||||
onDismiss = { dialog = null },
|
||||
onMerge = { target ->
|
||||
dialog = null
|
||||
onMerge(open.tag.id, target.id)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Delete ->
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.tags_delete_title, hash(open.tag.name)),
|
||||
// The count is the part that makes the consequence real — "it is on
|
||||
// 40 notes" is a different decision from "delete this tag?". It comes
|
||||
// from the LIST, the only call the core populates a count on.
|
||||
body =
|
||||
open.tag.count
|
||||
?.takeIf { it > 0 }
|
||||
?.let { stringResource(R.string.tags_delete_body_counted, it) }
|
||||
?: stringResource(R.string.tags_delete_body),
|
||||
footnote = stringResource(R.string.tags_delete_from_text),
|
||||
confirm = stringResource(R.string.tags_delete_confirm),
|
||||
onDismiss = { dialog = null },
|
||||
onConfirm = {
|
||||
dialog = null
|
||||
onDelete(open.tag.id)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Colour ->
|
||||
ColourDialog(
|
||||
tag = open.tag,
|
||||
onDismiss = { dialog = null },
|
||||
onPick = { key ->
|
||||
dialog = null
|
||||
onColour(open.tag.id, key)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `#` on the name, everywhere it is spoken about.
|
||||
*
|
||||
* The chips already wear it (`NoteCard.kt`, `EditorChrome.kt`) and it is the
|
||||
* reason these are called tags at all — a dialog that said "Delete grocery?" would
|
||||
* be talking about something else.
|
||||
*/
|
||||
private fun hash(name: String): String = "#$name"
|
||||
|
||||
@Composable
|
||||
private fun NewTagField(
|
||||
enabled: Boolean,
|
||||
onCreate: (String) -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
val submit = {
|
||||
if (text.isNotBlank()) {
|
||||
onCreate(text)
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
hint = R.string.tags_new_hint,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||
)
|
||||
TextButton(onClick = submit, enabled = enabled && text.isNotBlank()) {
|
||||
Text(stringResource(R.string.tags_create))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Said out loud rather than left as a blank screen — and it names the `#` route,
|
||||
* because the operator did not know `#tag` extraction existed at all (Scribe
|
||||
* #2949) and this is the natural place to say so.
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyTags() {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_empty_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.tags_empty_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TagRow(
|
||||
tag: Label,
|
||||
enabled: Boolean,
|
||||
onColour: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onMerge: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = labelTintFor(tag.name, tag.color)
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), CircleShape)
|
||||
.clickable(enabled = enabled, onClick = onColour),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = hash(tag.name),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = tint.tagInk(dark),
|
||||
)
|
||||
Text(
|
||||
text = countLabel(tag.count),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(R.string.tags_actions),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
// Panel.kt's MenuItem, not a bare DropdownMenuItem: every one of
|
||||
// these raises a dialog, and it closes the menu BEFORE acting so the
|
||||
// dialog cannot open underneath a menu still hanging over it.
|
||||
val close = { menuOpen = false }
|
||||
MenuItem(R.string.tags_rename, close, onRename)
|
||||
MenuItem(R.string.tags_merge, close, onMerge)
|
||||
MenuItem(R.string.tags_delete, close, onDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero is its own sentence, not "0 notes".
|
||||
*
|
||||
* A count of null means the core did not populate one — only `list_labels` does —
|
||||
* which is a different thing from a tag with no notes, so it reads as unknown
|
||||
* rather than as empty.
|
||||
*/
|
||||
@Composable
|
||||
private fun countLabel(count: Long?): String =
|
||||
when {
|
||||
count == null -> ""
|
||||
count <= 0L -> stringResource(R.string.tags_count_none)
|
||||
count == 1L -> stringResource(R.string.tags_count_one)
|
||||
else -> stringResource(R.string.tags_count, count.toInt())
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename, with the merge caught before it happens.
|
||||
*
|
||||
* The collision is detected HERE, against the list, rather than from what the
|
||||
* core returns: the merge survivor is whichever tag is older, so it may well be
|
||||
* the one being renamed, and an unchanged id afterwards would prove nothing.
|
||||
* Matching is case-insensitive because the core's is.
|
||||
*/
|
||||
@Composable
|
||||
private fun RenameDialog(
|
||||
tag: Label,
|
||||
others: List<Label>,
|
||||
onDismiss: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
onWouldMerge: (Label, String) -> Unit,
|
||||
) {
|
||||
var text by remember(tag.id) { mutableStateOf(tag.name) }
|
||||
val trimmed = text.trim()
|
||||
val clash =
|
||||
others.firstOrNull { it.id != tag.id && it.name.equals(trimmed, ignoreCase = true) }
|
||||
val submit = {
|
||||
when {
|
||||
trimmed.isEmpty() -> Unit
|
||||
clash != null -> onWouldMerge(clash, trimmed)
|
||||
else -> onRename(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_rename_title, hash(tag.name))) },
|
||||
text = {
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
hint = R.string.tags_new_hint,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = submit, enabled = trimmed.isNotEmpty()) {
|
||||
Text(stringResource(R.string.tags_rename_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge, with the direction stated and the survivor named.
|
||||
*
|
||||
* Unlike a rename — where the OLDER tag survives so that the outcome cannot
|
||||
* depend on which way round it was typed — this one is deliberate, so the
|
||||
* direction the person chooses IS the intent and is honoured. The price of that
|
||||
* is that the direction has to be unmissable, which is why the body names the tag
|
||||
* that stops existing and every row here is the one that survives.
|
||||
*/
|
||||
@Composable
|
||||
private fun MergeDialog(
|
||||
tag: Label,
|
||||
others: List<Label>,
|
||||
onDismiss: () -> Unit,
|
||||
onMerge: (Label) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_merge_title, hash(tag.name))) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_merge_body, hash(tag.name)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (others.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_merge_none),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
others.forEach { other ->
|
||||
val tint = labelTintFor(other.name, other.color)
|
||||
Text(
|
||||
text = hash(other.name),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = tint.tagInk(dark),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onMerge(other) }
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette, and since the per-note picker was removed this is the only place in
|
||||
* the product a colour is chosen.
|
||||
*
|
||||
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is
|
||||
* `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
|
||||
* means "let it pick" rather than "grey", and taking it away would leave no way
|
||||
* back to that.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColourDialog(
|
||||
tag: Label,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_colour_of, hash(tag.name))) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
NOTE_TINTS.forEach { (key, tint) ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onPick(key) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = tint.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
if (key == tag.color) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A destructive confirmation: what it is, what it costs, and one way out. */
|
||||
@Composable
|
||||
private fun ConfirmDialog(
|
||||
title: String,
|
||||
body: String,
|
||||
confirm: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
footnote: String? = null,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = body, style = MaterialTheme.typography.bodyMedium)
|
||||
footnote?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(confirm) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Everything the Tags screen renders from.
|
||||
*
|
||||
* [tags] comes from `list_labels`, which is the only call that populates a
|
||||
* `count` — the single-tag returns leave it null by design. So the counts a
|
||||
* confirmation dialog quotes are always the LIST's, never an operation's result.
|
||||
*/
|
||||
data class TagsState(
|
||||
val loading: Boolean = true,
|
||||
val tags: List<Label> = emptyList(),
|
||||
/** An in-flight write, for disabling the controls that would race it. */
|
||||
val busy: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Create, rename, recolour, delete and merge tags.
|
||||
*
|
||||
* A peer of the web's `LabelsModal.vue`, not a reduced companion — the same six
|
||||
* operations over the same core the desktop uses.
|
||||
*
|
||||
* ## Why every write re-lists
|
||||
*
|
||||
* A tag operation changes more than the row it names. A merge deletes one tag and
|
||||
* moves its notes; a delete changes nothing else's count but removes a drawer
|
||||
* lens; a rename can MERGE (see below) and so can make a different row vanish.
|
||||
* Re-listing after each write costs one cheap local SQLite read and removes a
|
||||
* whole class of "the screen thinks there are still two" bugs. Patching the list
|
||||
* in place would mean re-deriving, in Kotlin, rules the core already owns.
|
||||
*
|
||||
* ## Renaming can merge
|
||||
*
|
||||
* `rename_label` folds two tags together when the new name is one another tag
|
||||
* already holds, and the OLDER row survives (Scribe #3324). So it can return a
|
||||
* tag whose id is not the one passed in, and it can make another tag stop
|
||||
* existing. The screen asks first; this view model does not, because a
|
||||
* confirmation belongs to the surface with a person in front of it.
|
||||
*
|
||||
* ## Threading
|
||||
*
|
||||
* All of these are ordinary blocking FFI into SQLite — no async, no network — so
|
||||
* they take [Dispatchers.IO], exactly like the board's calls. Sync happens later:
|
||||
* the core marks the rows dirty and the next sync carries them.
|
||||
*/
|
||||
class TagsViewModel(
|
||||
private val core: ThoughtSync,
|
||||
/**
|
||||
* Called after any write that landed.
|
||||
*
|
||||
* The board holds its own snapshot of the tag list for the drawer, and its
|
||||
* current destination may BE one of these tags — deleting or merging that one
|
||||
* leaves it looking at a lens that no longer exists. Wiring the two together
|
||||
* explicitly is less magic than a shared event bus and makes the dependency
|
||||
* visible at the construction site, the same way [SyncViewModel] does it.
|
||||
*/
|
||||
private val onStoreChanged: () -> Unit,
|
||||
) : ViewModel() {
|
||||
var state by mutableStateOf(TagsState())
|
||||
private set
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
state =
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.fold(
|
||||
onSuccess = { state.copy(tags = it, loading = false, error = null) },
|
||||
// Unlike the drawer, this screen cannot fail quietly: it is
|
||||
// the only thing on the display, and an empty list here
|
||||
// would read as "you have no tags" rather than "I couldn't
|
||||
// look".
|
||||
onFailure = { state.copy(loading = false, error = it.describeTagFailure()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun create(name: String) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
// Find-or-create in the core: typing a name that exists in another case
|
||||
// attaches the existing tag rather than minting a near-duplicate.
|
||||
write { it.createLabel(trimmed) }
|
||||
}
|
||||
|
||||
fun rename(
|
||||
id: String,
|
||||
name: String,
|
||||
) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
write { it.renameLabel(id, trimmed) }
|
||||
}
|
||||
|
||||
fun setColour(
|
||||
id: String,
|
||||
colour: String,
|
||||
) = write { it.setLabelColor(id, colour) }
|
||||
|
||||
fun remove(id: String) = write { it.removeLabel(id) }
|
||||
|
||||
/**
|
||||
* Fold [sourceId] into [targetId]. The source stops existing.
|
||||
*
|
||||
* Directional and not undone by repeating it — the caller has to have said
|
||||
* which one survives before this runs, because afterwards there is nothing
|
||||
* left to read the direction from.
|
||||
*/
|
||||
fun merge(
|
||||
sourceId: String,
|
||||
targetId: String,
|
||||
) {
|
||||
if (sourceId == targetId) return
|
||||
write { it.mergeLabels(sourceId, targetId) }
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
state = state.copy(error = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one store write, then re-list and tell the board.
|
||||
*
|
||||
* `busy` is cleared in the same assignment that stores the result, so no path
|
||||
* out of here can leave the screen stuck with its controls disabled.
|
||||
*/
|
||||
private fun write(block: (ThoughtSync) -> Unit) {
|
||||
if (state.busy) return
|
||||
state = state.copy(busy = true, error = null)
|
||||
viewModelScope.launch {
|
||||
val failure =
|
||||
runCatching { withContext(Dispatchers.IO) { block(core) } }
|
||||
.exceptionOrNull()
|
||||
val tags =
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.getOrDefault(state.tags)
|
||||
state =
|
||||
state.copy(
|
||||
tags = tags,
|
||||
busy = false,
|
||||
error = failure?.describeTagFailure(),
|
||||
)
|
||||
// Even a FAILED write can have changed the store — a merge that threw
|
||||
// partway still moved rows — so the board is told either way.
|
||||
onStoreChanged()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
onStoreChanged: () -> Unit,
|
||||
): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = TagsViewModel(core, onStoreChanged) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The core reports problems as one error type carrying a message meant to be
|
||||
* shown, so the message is used when there is one.
|
||||
*/
|
||||
private fun Throwable.describeTagFailure(): String = message ?: "Something went wrong."
|
||||
@@ -1,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
|
||||
|
||||
@@ -6,20 +6,21 @@
|
||||
<string name="search_hint">Search your notes</string>
|
||||
<string name="search_clear">Clear search</string>
|
||||
<string name="nav_open">Open navigation</string>
|
||||
<string name="nav_labels">Labels</string>
|
||||
<string name="nav_labels">Tags</string>
|
||||
|
||||
<!-- Compose sheet -->
|
||||
<string name="compose_open">New note</string>
|
||||
<string name="compose_body_hint">Take a note…</string>
|
||||
<string name="compose_discard">Discard</string>
|
||||
<string name="compose_save">Save</string>
|
||||
|
||||
<!-- Board -->
|
||||
<string name="board_empty_note">Empty note</string>
|
||||
<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,15 +39,20 @@
|
||||
<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_remove_label">Remove tag</string>
|
||||
<string name="editor_reminder">Set a reminder</string>
|
||||
<string name="editor_more">More actions</string>
|
||||
<string name="editor_saving">Saving…</string>
|
||||
<string name="editor_unsaved">Not saved yet</string>
|
||||
<string name="editor_edited">Edited %1$s</string>
|
||||
<string name="editor_just_now">just now</string>
|
||||
<string name="editor_done">Done</string>
|
||||
<string name="editor_pin">Pin</string>
|
||||
<string name="editor_unpin">Unpin</string>
|
||||
<string name="editor_labels">Labels…</string>
|
||||
<string name="editor_labels">Tags…</string>
|
||||
<string name="editor_archive">Archive</string>
|
||||
<string name="editor_unarchive">Unarchive</string>
|
||||
<string name="editor_trash">Move to trash</string>
|
||||
@@ -60,12 +66,61 @@
|
||||
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
|
||||
<string name="editor_delete_forever_confirm">Delete</string>
|
||||
|
||||
<!-- Quick capture from outside the app: the share sheet and the text-selection
|
||||
toolbar. "New note" says what happens; the activity's own label would say
|
||||
who it happens in. -->
|
||||
<string name="capture_process_text">New note</string>
|
||||
|
||||
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
|
||||
schema still says Label, and no string here needs to know that. -->
|
||||
<string name="tags_manage">Manage tags</string>
|
||||
<string name="tags_title">Tags</string>
|
||||
<string name="tags_back">Back</string>
|
||||
<string name="tags_new_hint">New tag</string>
|
||||
<string name="tags_create">Create</string>
|
||||
<string name="tags_count">%1$d notes</string>
|
||||
<string name="tags_count_one">1 note</string>
|
||||
<string name="tags_count_none">No notes yet</string>
|
||||
<string name="tags_empty_title">No tags yet</string>
|
||||
<string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string>
|
||||
<string name="tags_actions">More actions</string>
|
||||
<string name="tags_colour">Colour</string>
|
||||
<string name="tags_colour_of">Colour for %1$s</string>
|
||||
|
||||
<string name="tags_rename">Rename</string>
|
||||
<string name="tags_rename_title">Rename %1$s</string>
|
||||
<string name="tags_rename_confirm">Rename</string>
|
||||
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
|
||||
#3324). A merge cannot be undone by repeating it and is reachable here by
|
||||
a typo, so it says so before it happens — same reasoning as #2116. -->
|
||||
<string name="tags_rename_merges_title">Merge with %1$s?</string>
|
||||
<string name="tags_rename_merges_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
|
||||
<string name="tags_rename_merges_confirm">Merge</string>
|
||||
|
||||
<string name="tags_merge">Merge into…</string>
|
||||
<string name="tags_merge_title">Merge %1$s into…</string>
|
||||
<!-- The survivor is named in the button, not just the title: this is the one
|
||||
operation here that repeating does not undo. -->
|
||||
<string name="tags_merge_body">Every note tagged %1$s will be tagged with the one you pick instead, and %1$s will stop existing. The notes are kept.</string>
|
||||
<string name="tags_merge_none">There is no other tag to merge into.</string>
|
||||
|
||||
<string name="tags_delete">Delete</string>
|
||||
<string name="tags_delete_title">Delete %1$s?</string>
|
||||
<string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string>
|
||||
<string name="tags_delete_body_counted">It is on %1$d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</string>
|
||||
<string name="tags_delete_confirm">Delete</string>
|
||||
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the
|
||||
row cannot un-write the word, so it comes back on that note's next edit —
|
||||
said here rather than left as a surprise. -->
|
||||
<string name="tags_delete_from_text">Tags written as #tag in a note come back when that note is next edited.</string>
|
||||
|
||||
<string name="tags_cancel">Cancel</string>
|
||||
|
||||
<!-- Pickers -->
|
||||
<string name="color_picker_title">Color</string>
|
||||
<string name="label_picker_title">Labels</string>
|
||||
<string name="label_new_hint">Type a label and press enter</string>
|
||||
<string name="label_from_tag">from #tag</string>
|
||||
<string name="label_none_body">No labels yet. Type one above, or write a #tag in a note and it becomes one.</string>
|
||||
<string name="label_picker_title">Tags</string>
|
||||
<string name="label_new_hint">Type a tag and press enter</string>
|
||||
<string name="label_from_tag">from the text</string>
|
||||
<string name="label_none_body">No tags yet. Type one above, or write a #tag in a note and it becomes one.</string>
|
||||
<string name="picker_next">Next</string>
|
||||
<string name="picker_set">Set</string>
|
||||
<string name="picker_time_title">Pick a time</string>
|
||||
@@ -122,6 +177,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>
|
||||
@@ -193,4 +250,9 @@
|
||||
|
||||
<!-- Errors -->
|
||||
<string name="error_dismiss">Dismiss</string>
|
||||
|
||||
<!-- The build, at the foot of Sync. Never blank: an APK with no versionName is
|
||||
a real state (a bare `gradlew assembleDebug` with no override) and saying
|
||||
so is better than an empty line that reads as a layout bug. -->
|
||||
<string name="build_unknown">unknown</string>
|
||||
</resources>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+199
-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!();
|
||||
@@ -333,6 +333,65 @@ impl ThoughtSync {
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Rename a label. Every note carrying it follows, because notes reference it
|
||||
/// by id and never by name.
|
||||
///
|
||||
/// Renaming onto a name another tag already holds MERGES the two, and the OLDER
|
||||
/// row is the survivor — it keeps its id and colour and takes the new spelling.
|
||||
/// Matching is case-insensitive, like `find_or_create_label`.
|
||||
///
|
||||
/// So this call can return a label whose id is NOT the one passed in, and it can
|
||||
/// make another label stop existing. A UI over it should say so before calling:
|
||||
/// the merge cannot be undone by repeating it, and here it is reachable by a
|
||||
/// typo in a text field. The web asks first (`stores/labels.ts`); this binding
|
||||
/// deliberately does not, because a confirmation belongs to the surface that has
|
||||
/// a person in front of it, not to the store.
|
||||
///
|
||||
/// `store::rename_label` and the server's PATCH implement the same rule, so the
|
||||
/// phone, the desktop and the web agree on which row survives.
|
||||
pub fn rename_label(&self, id: String, name: String) -> Result<Label, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::rename_label(&conn, &id, &name)
|
||||
.map(Label::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Recolour a label.
|
||||
///
|
||||
/// `color` is a palette KEY from the shared vocabulary (`NoteTint.kt` on this
|
||||
/// side), not a hex value — the point of the shared palette is that a colour
|
||||
/// picked on the phone resolves to the same swatch on the web and the desktop,
|
||||
/// which a literal colour could not promise across themes.
|
||||
pub fn set_label_color(&self, id: String, color: String) -> Result<Label, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::set_label_color(&conn, &id, &color)
|
||||
.map(Label::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Delete a label. The notes that carried it are NOT deleted — they simply stop
|
||||
/// carrying it, which is the thing a confirmation dialog has to say out loud.
|
||||
///
|
||||
/// A `#tag` in a body will re-derive the label on the next edit of that note.
|
||||
/// That is correct rather than a leak: the text mandates it, and deleting the
|
||||
/// row cannot un-write the word.
|
||||
pub fn remove_label(&self, id: String) -> Result<(), CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::remove_label(&conn, &id).map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Fold `source` into `target` and return the survivor.
|
||||
///
|
||||
/// DIRECTIONAL and NOT reversible by repeating it: source stops existing. Any
|
||||
/// UI over this has to name the survivor before it runs, because afterwards
|
||||
/// there is nothing left to read the direction from.
|
||||
pub fn merge_labels(&self, source_id: String, target_id: String) -> Result<Label, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::merge_labels(&conn, &source_id, &target_id)
|
||||
.map(Label::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
// ─────────────────────────────── sync ────────────────────────────────
|
||||
|
||||
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
||||
@@ -499,6 +558,66 @@ 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)
|
||||
}
|
||||
|
||||
/// Tell the core which app it is running inside, and which build of it.
|
||||
///
|
||||
/// Android has to say so because the core cannot: the same crate is compiled into
|
||||
/// the desktop app, and it used to announce every phone in the field as
|
||||
/// `thoughtsync-desktop` carrying the CORE crate's version — a number no build
|
||||
/// stamps and nobody has seen. The honest value is the installed package's own
|
||||
/// `versionName`, which is what Kotlin passes here.
|
||||
///
|
||||
/// Called once from `ThoughtSyncApplication.onCreate`, before anything can sync.
|
||||
#[uniffi::export]
|
||||
pub fn set_client_agent(name: String, version: String) {
|
||||
compat::set_client_agent(&name, &version);
|
||||
}
|
||||
|
||||
/// Every checklist item in a body, with the line each one sits on — so a renderer
|
||||
/// walking the body line by line knows which lines are boxes and what is in them.
|
||||
#[uniffi::export]
|
||||
pub fn checklist_items(body: String) -> Vec<BodyItem> {
|
||||
local::derive::extract_items(&body)
|
||||
.into_iter()
|
||||
.map(BodyItem::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every `#tag` in a body, with the line and the UTF-16 span each one occupies — so
|
||||
/// a card can colour the tag where it was typed instead of printing it twice.
|
||||
///
|
||||
/// The same argument as `checklist_items` above, and the same answer: the grammar for
|
||||
/// what a `#tag` is already exists in Rust, Python and TypeScript. Matching it a
|
||||
/// fourth time in Compose would be a fourth place for a tag to change shape when it
|
||||
/// syncs — and this one would fail silently, as the wrong characters tinted.
|
||||
#[uniffi::export]
|
||||
pub fn body_tags(body: String) -> Vec<BodyTag> {
|
||||
local::derive::extract_tag_spans(&body)
|
||||
.into_iter()
|
||||
.map(BodyTag::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
||||
/// block names, so these stay Rust-side.
|
||||
impl ThoughtSync {
|
||||
@@ -571,7 +690,6 @@ mod tests {
|
||||
fn draft(body: &str) -> NoteDraft {
|
||||
NoteDraft {
|
||||
body: body.to_string(),
|
||||
color: "default".to_string(),
|
||||
items: None,
|
||||
}
|
||||
}
|
||||
@@ -625,7 +743,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 +778,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 +798,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
|
||||
@@ -848,6 +965,81 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Renaming a tag onto a name another tag already holds MERGES the two, and the
|
||||
/// OLDER row is the survivor.
|
||||
///
|
||||
/// Before this, the bare UPDATE met `idx_labels_name` — unique on `lower(name)` —
|
||||
/// and the user got a raw "UNIQUE constraint failed" from SQLite. Merging is what
|
||||
/// a person means by typing an existing tag's name onto this one.
|
||||
#[test]
|
||||
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let older = app.create_label("grocery".to_string()).expect("older");
|
||||
// `created_at` is RFC3339 to the MILLISECOND. Without a gap the two rows can
|
||||
// share a timestamp, and then the tie-break is under test instead of the age
|
||||
// rule this test is about.
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let newer = app.create_label("errands".to_string()).expect("newer");
|
||||
|
||||
let one = app.create_note(draft("milk")).expect("note one");
|
||||
let two = app.create_note(draft("stamps")).expect("note two");
|
||||
app.set_note_labels(one.id.clone(), vec![older.id.clone()])
|
||||
.expect("tag one");
|
||||
app.set_note_labels(two.id.clone(), vec![newer.id.clone()])
|
||||
.expect("tag two");
|
||||
|
||||
// The YOUNGER one is renamed onto the older's name, in a different case —
|
||||
// matching is case-insensitive, and the survivor takes the spelling asked for.
|
||||
let survivor = app
|
||||
.rename_label(newer.id.clone(), "Grocery".to_string())
|
||||
.expect("a rename onto an existing name merges instead of failing");
|
||||
|
||||
assert_eq!(
|
||||
survivor.id, older.id,
|
||||
"the older row is the one that survives"
|
||||
);
|
||||
assert_eq!(survivor.name, "Grocery", "spelled the way the caller asked");
|
||||
|
||||
let all = app.list_labels().expect("list");
|
||||
assert_eq!(all.len(), 1, "the two became one");
|
||||
assert_eq!(all[0].id, older.id);
|
||||
assert_eq!(all[0].count, Some(2), "carrying every note from both sides");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The mirror of the test above. Renaming the OLDER one onto the younger's name
|
||||
/// still leaves the older row standing — it just changes its name.
|
||||
///
|
||||
/// This is the whole reason age decides rather than "whoever already held the
|
||||
/// name": otherwise the survivor depends on which way round someone typed it,
|
||||
/// and two devices tidying the same pair would disagree about which id exists.
|
||||
#[test]
|
||||
fn the_rename_merge_survivor_does_not_depend_on_the_direction() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let older = app.create_label("grocery".to_string()).expect("older");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let newer = app.create_label("errands".to_string()).expect("newer");
|
||||
|
||||
let survivor = app
|
||||
.rename_label(older.id.clone(), "errands".to_string())
|
||||
.expect("rename");
|
||||
|
||||
assert_eq!(survivor.id, older.id, "age wins in this direction too");
|
||||
assert_eq!(survivor.name, "errands");
|
||||
assert_ne!(
|
||||
survivor.id, newer.id,
|
||||
"the younger row is the one that went"
|
||||
);
|
||||
assert_eq!(app.list_labels().expect("list").len(), 1);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
|
||||
/// crate's dev-dependencies to assert one field is well-formed.
|
||||
fn chrono_free_parse(raw: &str) -> usize {
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
|
||||
+256
-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)
|
||||
}
|
||||
@@ -716,7 +820,57 @@ pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
|
||||
load_label(conn, &id)
|
||||
}
|
||||
|
||||
/// Rename a label. Renaming ONTO a name another label already holds MERGES the two.
|
||||
///
|
||||
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so
|
||||
/// the bare statement failed with a raw SQLite "UNIQUE constraint failed" that
|
||||
/// reached the user as database internals. Merging is the operator's call, and it
|
||||
/// is the reading that matches what a person means — typing an existing tag's name
|
||||
/// onto this one says "these are the same thing."
|
||||
///
|
||||
/// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one
|
||||
/// that already held the name" because age is the property neither participant's
|
||||
/// role can change: rename A→B and rename B→A must land on the same survivor, or
|
||||
/// the result depends on which way round someone happened to type it. Ties (two
|
||||
/// labels minted in the same millisecond) go to the incumbent, so the outcome is
|
||||
/// still deterministic.
|
||||
///
|
||||
/// Matching is case-insensitive, agreeing with `find_or_create_label` — "Groceries"
|
||||
/// finds "groceries", and the survivor ends up spelled the way the caller asked.
|
||||
pub fn rename_label(conn: &Connection, id: &str, name: &str) -> rusqlite::Result<Label> {
|
||||
let clash: Option<(String, String)> = conn
|
||||
.query_row(
|
||||
"SELECT id, created_at FROM labels WHERE lower(name) = lower(?1) AND id <> ?2",
|
||||
params![name, id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some((other_id, other_created)) = clash {
|
||||
let mine_created: String =
|
||||
conn.query_row("SELECT created_at FROM labels WHERE id = ?1", [id], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
// `created_at` is RFC3339 to the millisecond with a `Z`, so it is fixed-width
|
||||
// and lexicographic order IS chronological order — no parsing needed.
|
||||
let (survivor, doomed) = if other_created <= mine_created {
|
||||
(other_id, id.to_string())
|
||||
} else {
|
||||
(id.to_string(), other_id)
|
||||
};
|
||||
// Reuse the merge rather than re-implement it: it is the only place that
|
||||
// knows to mark every affected NOTE dirty before the delete cascades the
|
||||
// membership rows away, which is what makes the merge reach the server.
|
||||
merge_labels(conn, &doomed, &survivor)?;
|
||||
// The survivor may still carry the old spelling — it is the one that keeps
|
||||
// existing, so it is the one that has to end up named what was asked for.
|
||||
conn.execute(
|
||||
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
|
||||
params![name, now(), survivor],
|
||||
)?;
|
||||
return load_label(conn, &survivor);
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
|
||||
params![name, now(), id],
|
||||
|
||||
+66
-4
@@ -17,13 +17,28 @@
|
||||
//! `docs/sync.md` for the policy that governs when those numbers move.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The sync wire protocol this client speaks.
|
||||
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.
|
||||
@@ -158,10 +173,41 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
|
||||
}
|
||||
}
|
||||
|
||||
/// Who this client says it is, set once by the host application at startup.
|
||||
///
|
||||
/// THE CORE CANNOT KNOW THIS, and the value it used to invent was wrong twice. It
|
||||
/// was `thoughtsync-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into
|
||||
/// the desktop app AND the Android app — so every phone in the field announced
|
||||
/// itself as a desktop. The version was worse: `CARGO_PKG_VERSION` here is the
|
||||
/// version of the CORE crate, a number no build stamps and no user has ever seen,
|
||||
/// while the thing a reader of that header wants is the app's own build (note 3127
|
||||
/// §5 — with no version tags, the artifact's self-report is the only answer to
|
||||
/// "which build is this?").
|
||||
///
|
||||
/// So the host names itself. `OnceLock` because identity is fixed for the life of
|
||||
/// the process and a second caller should be ignored rather than race the first.
|
||||
static CLIENT_AGENT: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Name this client for the servers it talks to — `("thoughtsync-android", "2026.08.31.1204")`.
|
||||
///
|
||||
/// Call once at startup, before any sync. Calling twice is not an error and the
|
||||
/// first name wins; not calling it at all is visible in the header rather than
|
||||
/// silently plausible.
|
||||
pub fn set_client_agent(name: &str, version: &str) {
|
||||
let _ = CLIENT_AGENT.set(format!("{name}/{version}"));
|
||||
}
|
||||
|
||||
/// Headers this client puts on every request to a linked server, so the server can
|
||||
/// log or gate on client identity without a separate handshake round-trip.
|
||||
pub fn client_headers() -> [(&'static str, String); 2] {
|
||||
let agent = format!("thoughtsync-desktop/{}", env!("CARGO_PKG_VERSION"));
|
||||
// `unidentified/unknown`, never a plausible default. Nothing reads this header
|
||||
// today, which is exactly why a wrong value could sit in it for months: the
|
||||
// first person to look at a server log is the first person who could catch it,
|
||||
// and only if what they see is obviously a host that never introduced itself.
|
||||
let agent = CLIENT_AGENT
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "thoughtsync-unidentified/unknown".to_string());
|
||||
[
|
||||
("X-ThoughtSync-Client", agent),
|
||||
(
|
||||
@@ -360,10 +406,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn client_headers_identify_app_and_protocol() {
|
||||
// Sets the process-wide agent, which is why this test also owns the
|
||||
// assertion about it: a second test calling `set_client_agent` would race
|
||||
// this one for the OnceLock, and whichever lost would see the other's name.
|
||||
// One test, both branches, in order.
|
||||
assert!(
|
||||
client_headers()[0]
|
||||
.1
|
||||
.starts_with("thoughtsync-unidentified/"),
|
||||
"a host that never introduced itself must say so"
|
||||
);
|
||||
|
||||
set_client_agent("thoughtsync-test", "2026.08.31.1204");
|
||||
let headers = client_headers();
|
||||
assert_eq!(headers[0].0, "X-ThoughtSync-Client");
|
||||
assert!(headers[0].1.starts_with("thoughtsync-desktop/"));
|
||||
assert_eq!(headers[0].1, "thoughtsync-test/2026.08.31.1204");
|
||||
assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string());
|
||||
|
||||
// First name wins — a second host cannot rename a running process.
|
||||
set_client_agent("thoughtsync-impostor", "0");
|
||||
assert_eq!(client_headers()[0].1, "thoughtsync-test/2026.08.31.1204");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+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,15 @@
|
||||
# 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, and this script carried a
|
||||
# fallback that chased the `v*` release its manifest named. That came out once
|
||||
# `main` had published to `stable` for real (`b6673c6`); the two channels are the
|
||||
# same shape now and nothing here should special-case one of them again.
|
||||
# Pick one with `--channel dev` or `TS_CHANNEL=dev`. Through a pipe the options go
|
||||
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
|
||||
#
|
||||
@@ -41,7 +48,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,35 +89,12 @@ 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.
|
||||
manifest="$(curl -fsSL "$INSTANCE/$REPO/releases/download/stable/latest.json" 2>/dev/null || true)"
|
||||
stable_version="$(printf '%s' "$manifest" |
|
||||
grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 |
|
||||
sed -E 's/.*"([^"]+)"$/\1/')"
|
||||
if [ -n "$stable_version" ]; then
|
||||
json="$(curl -fsSL "$API/releases/tags/v$stable_version" 2>/dev/null)" ||
|
||||
die "the stable channel names $stable_version, but there is no v$stable_version release to install."
|
||||
else
|
||||
# No stable pointer yet — the channel predates the updater. Fall back to the
|
||||
# newest non-prerelease release, which is what stable meant before there was
|
||||
# a manifest to ask.
|
||||
json="$(curl -fsSL "$API/releases/latest" 2>/dev/null)" ||
|
||||
die "no stable release published yet — try --channel dev, or ask the maintainer to tag one."
|
||||
fi
|
||||
fi
|
||||
# ONE lookup, both channels. Each is a release whose tag never moves and whose assets
|
||||
# are pruned to the current build, so the tag alone names the newest build on that
|
||||
# channel — which is exactly what an installer wants and what the in-app updater
|
||||
# already reads.
|
||||
json="$(curl -fsSL "$API/releases/tags/$channel" 2>/dev/null)" ||
|
||||
die "the $channel channel has nothing published yet."
|
||||
|
||||
# Pull asset URLs straight out of the release JSON (no jq). Anchored on the closing
|
||||
# quote so a `…AppImage.sig` URL can't be truncated into a match of its own.
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -24,15 +24,21 @@ set -euo pipefail
|
||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}"
|
||||
: "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}"
|
||||
: "${APP_VERSION:?APP_VERSION is required (the version the bundles carry)}"
|
||||
# The version a PERSON reads, published beside the manifest so the image build can
|
||||
# describe the bundles it bakes in without re-deriving anything. Required rather
|
||||
# than defaulted: a missing value here would silently publish a sidecar naming the
|
||||
# wrong build, and there is nothing downstream that could catch it.
|
||||
: "${DISPLAY_VERSION:?DISPLAY_VERSION is required (the human-readable version)}"
|
||||
|
||||
# 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,41 +121,47 @@ 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
|
||||
# Both files go 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.
|
||||
|
||||
# Replace rather than duplicate: Forgejo rejects a second asset with the same name,
|
||||
# and this file is rewritten on every publish by design.
|
||||
old_id="$(printf '%s' "$target_assets" \
|
||||
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"latest\.json\"" \
|
||||
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
|
||||
if [ -n "${old_id:-}" ]; then
|
||||
echo "==> Removing the previous latest.json (id $old_id)"
|
||||
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$target_id/assets/$old_id" >/dev/null
|
||||
fi
|
||||
# and these files are rewritten on every publish by design.
|
||||
replace_asset() {
|
||||
local path="$1" name="$2" escaped old_id
|
||||
escaped="${name//./\\.}"
|
||||
old_id="$(printf '%s' "$assets" \
|
||||
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"$escaped\"" \
|
||||
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
|
||||
if [ -n "${old_id:-}" ]; then
|
||||
echo "==> Removing the previous $name (id $old_id)"
|
||||
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$release_id/assets/$old_id" >/dev/null
|
||||
fi
|
||||
echo "==> Uploading $name to $RELEASE_TAG"
|
||||
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$release_id/assets?name=$name" \
|
||||
-F "attachment=@$path" >/dev/null
|
||||
}
|
||||
|
||||
echo "==> Uploading latest.json to $MANIFEST_TAG"
|
||||
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$target_id/assets?name=latest.json" \
|
||||
-F "attachment=@$work/latest.json" >/dev/null
|
||||
replace_asset "$work/latest.json" "latest.json"
|
||||
|
||||
echo "==> Done. $MANIFEST_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)."
|
||||
# The version pair, for whoever needs to describe these bundles without rebuilding
|
||||
# them — today the image build, which bakes the desktop clients in and writes each
|
||||
# one a sidecar (`packaging/fetch-clients.sh`).
|
||||
#
|
||||
# It is published HERE, beside the manifest, because this is the step that speaks
|
||||
# for what the channel serves: both files are written in the same breath from the
|
||||
# same two values, so they cannot disagree about which build is current. A consumer
|
||||
# deriving the version from its own checkout instead would describe these bytes
|
||||
# with whatever commit it happened to be on.
|
||||
#
|
||||
# No `size` or `sha256` — those are per-artifact and there are four. Whoever
|
||||
# downloads a bundle measures the bytes it actually got, which is the only way to
|
||||
# tell a truncated download from a whole one.
|
||||
printf '{\n "version_name": "%s",\n "version_code": "%s"\n}\n' \
|
||||
"$DISPLAY_VERSION" "$APP_VERSION" > "$work/thoughtsync-desktop.json"
|
||||
replace_asset "$work/thoughtsync-desktop.json" "thoughtsync-desktop.json"
|
||||
|
||||
echo "==> Done. $RELEASE_TAG now advertises $DISPLAY_VERSION ($APP_VERSION) for ${#entries[@]} platform(s)."
|
||||
|
||||
# --- prune superseded builds from a rolling channel ---------------------------
|
||||
#
|
||||
@@ -182,7 +194,7 @@ if [ "${PRUNE_OLD_ASSETS:-false}" = "true" ]; then
|
||||
# every desktop push regardless. That is exactly what happened on run
|
||||
# 4098, which swept the APK run 4092 had just published.
|
||||
case "$asset_name" in
|
||||
latest.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
|
||||
latest.json|thoughtsync-desktop.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
|
||||
*"$APP_VERSION"*) continue ;;
|
||||
esac
|
||||
echo " removing $asset_name"
|
||||
|
||||
@@ -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"]
|
||||
@@ -52,3 +64,8 @@ tauri-plugin-log = "2"
|
||||
# the plugin declares android support level "none", which is why the Android client
|
||||
# gets a server-served update path instead (Scribe note 2725).
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
# The system-wide quick-capture hotkey. Desktop only by nature — Android has no
|
||||
# concept of a global shortcut, and its half of this feature is a share-sheet
|
||||
# intent filter instead.
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
fn main() {
|
||||
// Cargo does NOT track an `option_env!` variable on its own — the macro is
|
||||
// expanded at compile time and nothing records that the crate depends on it.
|
||||
// So without this line, a cached `target/` would keep a binary reporting
|
||||
// whatever version the previous build baked, and the footer would confidently
|
||||
// name the wrong build. The desktop lane has no cache today, which is exactly
|
||||
// why this is easy to forget the day one is added.
|
||||
//
|
||||
// See DISPLAY_VERSION in `src/commands/local.rs`.
|
||||
println!("cargo::rerun-if-env-changed=THOUGHTSYNC_DISPLAY_VERSION");
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Core capability for the main ThoughtSync window.",
|
||||
"windows": ["main"],
|
||||
"description": "Core capability for the ThoughtSync windows: the board and the quick-capture window.",
|
||||
"windows": ["main", "capture"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Quick capture: a system-wide hotkey that opens a small window to type into.
|
||||
//!
|
||||
//! The point is capture WITHOUT the app. Bringing the whole board forward to write
|
||||
//! one line is the friction this removes, so the shortcut opens a small window of
|
||||
//! its own rather than focusing `main` — and that window closes itself the moment
|
||||
//! the note is saved.
|
||||
//!
|
||||
//! ## Why the shortcut is configurable, and why it starts unset
|
||||
//!
|
||||
//! A global shortcut is the one setting in this app that can collide with software
|
||||
//! it knows nothing about. Whatever default is picked is a key combination taken
|
||||
//! away from something on somebody's machine, silently, at install time. So there
|
||||
//! is no default: the feature is off until someone chooses a combination, and
|
||||
//! choosing one is how it turns on.
|
||||
//!
|
||||
//! The suggestion the settings screen offers (`CommandOrControl+Shift+N`) lives in
|
||||
//! the frontend, not here. It is a UI affordance — a starting point put in front of
|
||||
//! someone — and this side accepts any combination the OS will take, so a constant
|
||||
//! here would be a second copy of a string only the UI ever reads.
|
||||
//!
|
||||
//! ## Failure has to be visible
|
||||
//!
|
||||
//! Registering can fail — the combination may already be held by the window
|
||||
//! manager or another app, and on Wayland a compositor may refuse global grabs
|
||||
//! outright. A hotkey that quietly does nothing is worse than one that was never
|
||||
//! offered, because there is nothing to look at and nothing to fix. So the stored
|
||||
//! shortcut and the LIVE registration are reported separately: see
|
||||
//! [`CaptureShortcut`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
|
||||
use thoughtsync_core::local::{store, Db};
|
||||
|
||||
const SHORTCUT_PREF: &str = "capture_shortcut";
|
||||
|
||||
/// The window the hotkey opens. Also the label the capability file grants to.
|
||||
pub const CAPTURE_WINDOW: &str = "capture";
|
||||
|
||||
/// Emitted to the main window after a capture is saved, so the board reloads.
|
||||
///
|
||||
/// The two windows hold separate copies of the frontend and therefore separate
|
||||
/// Pinia stores; nothing in the capture window's store can reach the board's. The
|
||||
/// note is already in SQLite by the time this fires — this only says "look again".
|
||||
pub const CAPTURED_EVENT: &str = "thoughtsync://captured";
|
||||
|
||||
/// The stored shortcut and whether it is actually live.
|
||||
///
|
||||
/// Two fields rather than one because they genuinely disagree: a combination can
|
||||
/// be saved and refuse to register, and the person needs to be told which of those
|
||||
/// they are looking at. `registered: false` with a non-empty `shortcut` is the
|
||||
/// "something else already has this" case.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CaptureShortcut {
|
||||
/// The stored combination, or empty when quick capture is off.
|
||||
pub shortcut: String,
|
||||
/// Whether the OS accepted it. Always false when `shortcut` is empty.
|
||||
pub registered: bool,
|
||||
}
|
||||
|
||||
fn stored(db: &Db) -> Result<String, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Ok(store::pref(&conn, SHORTCUT_PREF)
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Open (or focus) the capture window.
|
||||
///
|
||||
/// Reused rather than recreated: holding one window and showing it is what makes
|
||||
/// the second press feel instant, and it means a half-typed capture survives the
|
||||
/// window being dismissed and reopened.
|
||||
///
|
||||
/// `always_on_top` and `center` because this is summoned over whatever you were
|
||||
/// doing — a capture window that opens behind the app you called it from has
|
||||
/// failed at the only thing it does.
|
||||
fn open_capture_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// `index.html?capture=1` rather than a `/capture` path: the bundled assets are
|
||||
// served as files, so a path with no file behind it is a 404 in the production
|
||||
// build even though it routes fine under the dev server. A query string is
|
||||
// carried through untouched and the router reads it on boot.
|
||||
let built = WebviewWindowBuilder::new(
|
||||
app,
|
||||
CAPTURE_WINDOW,
|
||||
WebviewUrl::App("index.html?capture=1".into()),
|
||||
)
|
||||
.title("Quick capture")
|
||||
.inner_size(520.0, 220.0)
|
||||
.min_inner_size(360.0, 160.0)
|
||||
.resizable(true)
|
||||
.always_on_top(true)
|
||||
.center()
|
||||
.skip_taskbar(true)
|
||||
.build();
|
||||
|
||||
match built {
|
||||
Ok(window) => {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
// Never a panic and never fatal: failing to open a capture window must not
|
||||
// take down an app whose board is working fine.
|
||||
Err(e) => log::error!("could not open the capture window: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register `shortcut`, replacing whatever was live.
|
||||
///
|
||||
/// Unregisters everything first rather than tracking the previous binding: this
|
||||
/// app owns exactly one global shortcut, so "all of ours" and "the old one" are
|
||||
/// the same set, and keeping a copy of it is one more thing to get out of step.
|
||||
fn register(app: &AppHandle, shortcut: &str) -> Result<(), String> {
|
||||
let manager = app.global_shortcut();
|
||||
let _ = manager.unregister_all();
|
||||
if shortcut.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let parsed: Shortcut = shortcut
|
||||
.parse()
|
||||
.map_err(|_| format!("'{shortcut}' is not a shortcut this system understands."))?;
|
||||
manager
|
||||
.on_shortcut(parsed, |app, _shortcut, event| {
|
||||
// Pressed only. Without this the window is opened on the press AND on
|
||||
// the release, and the second one lands on the window the first opened.
|
||||
if event.state == ShortcutState::Pressed {
|
||||
open_capture_window(app);
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("Something else on this system is already using it ({e})."))
|
||||
}
|
||||
|
||||
/// Restore the stored shortcut at startup.
|
||||
///
|
||||
/// Best-effort by construction: a combination that worked when it was chosen can
|
||||
/// be taken by something installed later, and the app must still open. The failure
|
||||
/// is logged and the UI will show it as not registered when the settings screen is
|
||||
/// next opened.
|
||||
pub fn restore(app: &AppHandle, db: &Db) {
|
||||
let shortcut = match stored(db) {
|
||||
Ok(s) if !s.is_empty() => s,
|
||||
Ok(_) => return,
|
||||
Err(e) => {
|
||||
log::warn!("could not read the capture shortcut: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match register(app, &shortcut) {
|
||||
Ok(()) => log::info!("quick capture is on: {shortcut}"),
|
||||
Err(e) => log::warn!("quick capture shortcut '{shortcut}' did not register: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn capture_shortcut_get(app: AppHandle, db: State<'_, Db>) -> Result<CaptureShortcut, String> {
|
||||
let shortcut = stored(&db)?;
|
||||
// Asked of the manager rather than remembered from startup: the answer can
|
||||
// have changed since, and a settings screen that reports a stale success is
|
||||
// the exact thing this pair of fields exists to prevent.
|
||||
let registered = !shortcut.is_empty()
|
||||
&& shortcut
|
||||
.parse::<Shortcut>()
|
||||
.map(|s| app.global_shortcut().is_registered(s))
|
||||
.unwrap_or(false);
|
||||
Ok(CaptureShortcut {
|
||||
shortcut,
|
||||
registered,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store a shortcut and make it live, or clear it with an empty string.
|
||||
///
|
||||
/// Registers BEFORE storing, so a combination the system refuses is not written
|
||||
/// down as though it worked — the person would reopen the settings and find it
|
||||
/// listed as their shortcut while nothing happened when they pressed it.
|
||||
#[tauri::command]
|
||||
pub fn capture_shortcut_set(
|
||||
shortcut: String,
|
||||
app: AppHandle,
|
||||
db: State<'_, Db>,
|
||||
) -> Result<CaptureShortcut, String> {
|
||||
let wanted = shortcut.trim().to_string();
|
||||
register(&app, &wanted)?;
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
store::set_pref(&conn, SHORTCUT_PREF, &wanted).map_err(|e| e.to_string())?;
|
||||
log::info!(
|
||||
"quick capture shortcut {}",
|
||||
if wanted.is_empty() {
|
||||
"cleared".to_string()
|
||||
} else {
|
||||
format!("set to {wanted}")
|
||||
}
|
||||
);
|
||||
Ok(CaptureShortcut {
|
||||
shortcut: wanted.clone(),
|
||||
registered: !wanted.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Hide the capture window and tell the board to reload.
|
||||
///
|
||||
/// Hidden rather than closed so the next press has a window to show instead of one
|
||||
/// to build. Called after a save and on Escape alike; `saved` is what decides
|
||||
/// whether the board is told to look again.
|
||||
#[tauri::command]
|
||||
pub fn capture_done(saved: bool, app: AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) {
|
||||
window.hide().map_err(|e| e.to_string())?;
|
||||
}
|
||||
if saved {
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
// Failure here is cosmetic — the note is saved either way and the board
|
||||
// will show it on its next load — so it is logged, not raised.
|
||||
if let Err(e) = main.emit(CAPTURED_EVENT, ()) {
|
||||
log::warn!("could not tell the board about a capture: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -31,7 +31,9 @@ pub fn config_get(db: State<'_, Db>) -> PublicConfig {
|
||||
PublicConfig {
|
||||
site_name: "ThoughtSync".to_string(),
|
||||
allow_registration: false,
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
// The build a person reads, baked at compile time — see crate::display_version
|
||||
// for why this is neither CARGO_PKG_VERSION nor the updater's ordering key.
|
||||
version: crate::display_version().to_string(),
|
||||
enable_url_unfurl: false,
|
||||
trash_retention_days: retention_days.max(0) as u32,
|
||||
}
|
||||
|
||||
@@ -9,10 +9,42 @@
|
||||
//! remains here is the Tauri command surface (`commands`), desktop integration
|
||||
//! (menu-entry install for the Linux AppImage), the in-app updater, and boot.
|
||||
|
||||
mod capture;
|
||||
mod commands;
|
||||
mod integration;
|
||||
mod update;
|
||||
|
||||
/// The build a PERSON reads, baked in by the desktop lane at compile time.
|
||||
///
|
||||
/// Lives at the crate root because it has two readers — `config_get`, which puts it
|
||||
/// in the UI, and `log_environment`, which puts it in the log — and this repo has
|
||||
/// spent several issues on one fact held in two places (2181, 2182, 2183).
|
||||
///
|
||||
/// `option_env!`, not `env!`: a local `cargo tauri build` sets nothing, and this has
|
||||
/// to keep compiling. `None` becomes "unknown" at each call site rather than a
|
||||
/// plausible-looking default — note 3127 §5 makes this string the only answer to
|
||||
/// "which build is this?" now that there are no version tags, so there is nothing
|
||||
/// left to contradict it if it lies. An honest "I cannot say" is the only safe wrong
|
||||
/// answer.
|
||||
///
|
||||
/// NOT `CARGO_PKG_VERSION`, which both readers used to use, and which was wrong on
|
||||
/// every build ever shipped: `cargo tauri build --config '{"version": ...}'`
|
||||
/// overrides `tauri.conf.json`, not Cargo's own metadata, so the literal `0.2.0` in
|
||||
/// Cargo.toml is what reached the UI and the log regardless of what was built.
|
||||
///
|
||||
/// NOT the ordering key either. That value — `1.0.<minutes>`, which the override
|
||||
/// above does set — is the opaque value Tauri's updater compares; it lands in bundle
|
||||
/// filenames and `latest.json` and must never be shown to a person (#3144). Two
|
||||
/// values, two audiences. `update.rs` deliberately still reads the key, through
|
||||
/// `app.package_info().version`, because a comparator is exactly what it is.
|
||||
const DISPLAY_VERSION: Option<&str> = option_env!("THOUGHTSYNC_DISPLAY_VERSION");
|
||||
|
||||
/// The baked build, or the honest "I cannot say". The only way in — the const is
|
||||
/// private so no caller can reach past the fallback.
|
||||
pub(crate) fn display_version() -> &'static str {
|
||||
DISPLAY_VERSION.unwrap_or("unknown")
|
||||
}
|
||||
|
||||
// The store and the sync engine live in the shared `thoughtsync-core` crate, which
|
||||
// the Android client binds through uniffi (Scribe note 2730). Aliased to their old
|
||||
// names so every call site below reads exactly as it did when they were modules of
|
||||
@@ -22,6 +54,12 @@ use thoughtsync_core::{local, sync};
|
||||
pub fn run() {
|
||||
use tauri_plugin_log::{Target, TargetKind};
|
||||
|
||||
// Introduce ourselves to any server this app links to, BEFORE anything can sync.
|
||||
// The core cannot work this out — it is compiled into the Android app too — so
|
||||
// the header says "desktop" only because the desktop says so here, and carries
|
||||
// the build a person can read rather than the core crate's own version.
|
||||
sync::compat::set_client_agent("thoughtsync-desktop", display_version());
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
harden_linux_webkit_rendering();
|
||||
|
||||
@@ -43,6 +81,10 @@ pub fn run() {
|
||||
// build without a signing key still starts normally and simply reports that
|
||||
// updates aren't configured.
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
// The quick-capture hotkey. Registering the combination itself happens in
|
||||
// `setup`, once the store is open and can be asked which one to use — the
|
||||
// plugin only has to exist before then.
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
// Attachment bytes are served to the webview from the local blob store
|
||||
// (M10.7f). Registered on the BUILDER because a scheme has to exist before
|
||||
// the webview is created; the directory it reads from arrives later, in
|
||||
@@ -81,6 +123,9 @@ pub fn run() {
|
||||
// in this directory saying which one the user picked (issue 2183).
|
||||
update::adopt_installer_channel(&db, &dir);
|
||||
sweep_local_trash(&db);
|
||||
// Before the store is handed to the app: `restore` needs to read the
|
||||
// stored shortcut out of it, and after `manage` the Db has moved.
|
||||
capture::restore(app.handle(), &db);
|
||||
app.manage(db);
|
||||
// Attachment bytes live beside the database, filed by content hash, so a
|
||||
// synced image is readable with no network (M10.7d).
|
||||
@@ -139,6 +184,9 @@ pub fn run() {
|
||||
update::update_channel_set,
|
||||
update::update_check,
|
||||
update::update_install,
|
||||
capture::capture_shortcut_get,
|
||||
capture::capture_shortcut_set,
|
||||
capture::capture_done,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running the ThoughtSync desktop app");
|
||||
@@ -219,8 +267,8 @@ fn log_event(level: String, message: String) {
|
||||
fn log_environment(app: &tauri::App) {
|
||||
use tauri::Manager;
|
||||
log::info!(
|
||||
"ThoughtSync desktop v{} starting ({} {})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"ThoughtSync desktop {} starting ({} {})",
|
||||
display_version(),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -30,6 +36,10 @@ pinning.
|
||||
If you want a specific build — testing something, or holding back — drop it in
|
||||
`/var/thoughtsync/client/` and it wins over the image's copy.
|
||||
|
||||
That directory is shared with the desktop clients the server hands out, and
|
||||
**precedence is decided per platform**: dropping in an APK overrides the baked APK
|
||||
and leaves every other client alone. It is one directory, not one choice.
|
||||
|
||||
Two files, both required:
|
||||
|
||||
| File | What it is |
|
||||
|
||||
+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();
|
||||
@@ -27,6 +27,23 @@ const ui = useUiStore();
|
||||
// Sync is a desktop-app concern: the web build already IS the server's UI.
|
||||
const desktopApp = isDesktop();
|
||||
|
||||
// The build, for the dim line at the foot of the rail (#3181).
|
||||
//
|
||||
// NEVER BLANK. "unknown" is the honest answer when the value is missing, and an
|
||||
// empty space is a bug that reads as a design choice. Note 3127 §5: with version
|
||||
// tags gone this is the only answer to "which build is this?", so it has to be
|
||||
// either right or visibly absent.
|
||||
//
|
||||
// One slot, two artifacts, and that is deliberate rather than sloppy. In the
|
||||
// browser `repo` is `rest`, so this is the SERVER's version; in the desktop shell
|
||||
// `repo` is `local` and `config_get` returns the desktop build's own. Each surface
|
||||
// names the thing the person is actually looking at. A linked server's version is
|
||||
// a different question and Sync answers it separately.
|
||||
const buildVersion = computed(() => config.version || "unknown");
|
||||
const buildLabel = computed(
|
||||
() => `ThoughtSync ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`,
|
||||
);
|
||||
|
||||
async function removeView(f: SavedFilter) {
|
||||
if (!window.confirm(`Delete the "${f.name}" view?`)) return;
|
||||
try {
|
||||
@@ -173,8 +190,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
|
||||
@@ -413,7 +432,7 @@ async function signOut() {
|
||||
@click="drawer = false"
|
||||
></div>
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-64 -translate-x-full flex-col overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
|
||||
:class="drawer ? 'translate-x-0' : ''"
|
||||
>
|
||||
<nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
|
||||
@@ -422,18 +441,18 @@ async function signOut() {
|
||||
</RouterLink>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between px-3 pb-1">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Tags</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200"
|
||||
title="Edit labels"
|
||||
aria-label="Edit labels"
|
||||
title="Manage tags"
|
||||
aria-label="Manage tags"
|
||||
@click="managing = true"
|
||||
>
|
||||
<Icon name="pencil" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No labels yet</p>
|
||||
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No tags yet</p>
|
||||
<RouterLink
|
||||
v-for="lb in labels.items"
|
||||
:key="lb.id"
|
||||
@@ -443,7 +462,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>
|
||||
@@ -525,6 +544,17 @@ async function signOut() {
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- The build. `mt-auto` puts it at the foot of the rail when the nav is
|
||||
short and lets it simply follow when the nav has scrolled.
|
||||
`select-all` because the one thing anybody does with this is copy it
|
||||
into a bug report. -->
|
||||
<p
|
||||
class="mt-auto select-all px-3 pt-6 text-[11px] text-neutral-400 dark:text-neutral-500"
|
||||
:title="buildLabel"
|
||||
>
|
||||
{{ buildVersion }}
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just
|
||||
|
||||
@@ -11,18 +11,16 @@ withDefaults(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- The look comes from `.btn` + a variant in style.css, NOT from here. A
|
||||
download has to be an <a> (only an anchor can carry an href and hand the
|
||||
transfer to the browser), so the shape has to live somewhere both elements
|
||||
can wear it. The disabled: variants stay local — an anchor has no
|
||||
:disabled, so they are not shared and never were. -->
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold transition
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2
|
||||
focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950
|
||||
disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="
|
||||
variant === 'primary'
|
||||
? 'bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700'
|
||||
: 'text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800'
|
||||
"
|
||||
class="btn disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="variant === 'primary' ? 'btn-primary' : 'btn-ghost'"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
// Every client this server holds, with the one that fits the visitor on top.
|
||||
//
|
||||
// Five artifacts is where a downloads page turns into a table of filenames and
|
||||
// stops being a product. So this LEADS with the download that fits the machine
|
||||
// asking and keeps the rest quiet but visible — nothing is behind a disclosure,
|
||||
// because a wrong guess must cost a person nothing.
|
||||
import { computed } from "vue";
|
||||
import { useConfigStore, type ClientRelease } from "../stores/config";
|
||||
|
||||
const config = useConfigStore();
|
||||
|
||||
type Family = "android" | "windows" | "linux" | "mac" | "ios" | "other";
|
||||
|
||||
/**
|
||||
* Which OS is asking, from the user agent.
|
||||
*
|
||||
* ORDER IS THE WHOLE ALGORITHM. Android's UA contains "Linux", an iPad's contains
|
||||
* "Mac OS X", and a Chromebook's contains "X11" — so each narrow test has to run
|
||||
* before the broad one that would otherwise swallow it.
|
||||
*
|
||||
* `navigator.userAgent` rather than `userAgentData`: the reduced UA Chrome now
|
||||
* sends still carries the platform token, which is the only thing being asked
|
||||
* for, and one code path beats two for a guess that is allowed to be wrong.
|
||||
*/
|
||||
function detectFamily(ua: string): Family {
|
||||
if (/Android/i.test(ua)) return "android";
|
||||
if (/Windows/i.test(ua)) return "windows";
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return "ios";
|
||||
if (/Mac OS X|Macintosh/i.test(ua)) return "mac";
|
||||
// CrOS lands here on purpose: a Chromebook's Linux container is a Debian one,
|
||||
// which is the first thing the Linux group offers.
|
||||
if (/Linux|X11|CrOS/i.test(ua)) return "linux";
|
||||
return "other";
|
||||
}
|
||||
|
||||
// What to lead with per family, in the order someone on it should see them.
|
||||
//
|
||||
// Linux gets all three because the UA says "Linux" and nothing about dpkg or
|
||||
// pacman — there is no more specific answer to be had, so the three are named for
|
||||
// the DISTRO a person knows rather than the package format they may not.
|
||||
//
|
||||
// macOS and iOS lead with nothing. There is no build for either, and an empty
|
||||
// lead is the honest way to say so — see `missingPlatform` below.
|
||||
const LEAD: Record<Family, string[]> = {
|
||||
android: ["android"],
|
||||
windows: ["windows"],
|
||||
linux: ["linux-deb", "linux-pacman", "linux-appimage"],
|
||||
mac: [],
|
||||
ios: [],
|
||||
other: [],
|
||||
};
|
||||
|
||||
const FAMILY_TITLE: Record<Family, string> = {
|
||||
android: "Android",
|
||||
windows: "Windows",
|
||||
linux: "Linux",
|
||||
mac: "macOS",
|
||||
ios: "iOS",
|
||||
other: "This machine",
|
||||
};
|
||||
|
||||
// Read once. The UA does not change while the page is open, and making it
|
||||
// reactive would only invite someone to think it could.
|
||||
const family = detectFamily(navigator.userAgent);
|
||||
|
||||
// The lead offers this server actually holds. A platform in LEAD that the server
|
||||
// has no build for simply is not here — the guess never conjures a download.
|
||||
const lead = computed(() =>
|
||||
LEAD[family]
|
||||
.map((id) => config.clients[id])
|
||||
.filter((c): c is ClientRelease => Boolean(c)),
|
||||
);
|
||||
|
||||
const others = computed(() => {
|
||||
const leading = new Set(lead.value.map((c) => c.platform));
|
||||
// Object.values keeps the server's own PLATFORMS order, which is a deliberate
|
||||
// one (phone first, then the desktop bundles) and not worth re-deciding here.
|
||||
return Object.values(config.clients).filter((c) => !leading.has(c.platform));
|
||||
});
|
||||
|
||||
const groups = computed(() => {
|
||||
const out: { title: string; releases: ClientRelease[]; prominent: boolean }[] = [];
|
||||
if (lead.value.length) {
|
||||
out.push({ title: FAMILY_TITLE[family], releases: lead.value, prominent: true });
|
||||
}
|
||||
if (others.value.length) {
|
||||
out.push({
|
||||
// Without a lead there is no "other" — the whole list is the choice.
|
||||
title: lead.value.length ? "Other platforms" : "Choose a platform",
|
||||
releases: others.value,
|
||||
prominent: false,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Said plainly, so a Mac reads as "not yet" rather than as a page that failed to
|
||||
// find its own downloads.
|
||||
const missingPlatform = computed(() =>
|
||||
!lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "",
|
||||
);
|
||||
|
||||
// One decimal below 10 MB, none above: these sit in one list where a 2.7 MB
|
||||
// package and a 95 MB AppImage are compared, and "3 MB" next to "95 MB" loses the
|
||||
// only distinction that matters at the small end.
|
||||
function readableSize(bytes: number): string {
|
||||
const mb = bytes / 1024 / 1024;
|
||||
return `${mb < 10 ? mb.toFixed(1) : mb.toFixed(0)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Nothing at all on a server with no clients — a brand-new instance before its
|
||||
first image carrying them. An empty section would be a promise it can't keep. -->
|
||||
<section v-if="groups.length" class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800">
|
||||
<h2 class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Get the apps</h2>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">
|
||||
Served by this server, so they always speak the same sync protocol.
|
||||
</p>
|
||||
<p v-if="missingPlatform" class="mt-1 text-xs text-neutral-400">
|
||||
There's no {{ missingPlatform }} build yet.
|
||||
</p>
|
||||
|
||||
<div v-for="group in groups" :key="group.title" class="mt-4">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-400">{{ group.title }}</p>
|
||||
<ul class="mt-2 flex flex-col gap-2">
|
||||
<li
|
||||
v-for="client in group.releases"
|
||||
:key="client.platform"
|
||||
class="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-neutral-800 dark:text-neutral-100">{{ client.label }}</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">
|
||||
<!-- `unknown` rather than a blank or a plausible default: with no
|
||||
second source to contradict it, a wrong version here is a wrong
|
||||
answer nothing can catch. Not knowing which build it is, is also
|
||||
not a reason to withhold the download. -->
|
||||
Version {{ client.version || "unknown" }} · {{ readableSize(client.size) }}<span
|
||||
v-if="client.platform === 'linux-appimage'"
|
||||
>, and the only one that updates itself in place</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<!-- An anchor, never BaseButton and never a fetch: these are 3–95 MB and
|
||||
the browser's own download manager handles the transfer better than
|
||||
anything this app would do with a blob. It wears `.btn` — the same
|
||||
definition BaseButton wears, so the two cannot drift.
|
||||
|
||||
`download` carries no filename because the server already names the
|
||||
file in its Content-Disposition, which browsers prefer over this
|
||||
attribute anyway — a value here would be inert and read as if it
|
||||
weren't. -->
|
||||
<a
|
||||
:href="client.url"
|
||||
download
|
||||
class="btn shrink-0"
|
||||
:class="group.prominent ? 'btn-primary' : 'btn-ghost'"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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,22 +107,8 @@ 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>
|
||||
<span class="w-16 shrink-0 text-xs text-neutral-400">Tags</span>
|
||||
<button
|
||||
v-for="lb in labels.items"
|
||||
:key="lb.id"
|
||||
|
||||
@@ -46,7 +46,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
|
||||
<template>
|
||||
<div ref="root" class="relative">
|
||||
<button type="button" class="icon-btn" title="Labels" aria-label="Labels" @click="open = !open">
|
||||
<button type="button" class="icon-btn" title="Tags" aria-label="Tags" @click="open = !open">
|
||||
<Icon name="tag" />
|
||||
</button>
|
||||
<div
|
||||
@@ -56,7 +56,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
<input
|
||||
v-model="filter"
|
||||
type="text"
|
||||
placeholder="Label note…"
|
||||
placeholder="Tag note…"
|
||||
class="mb-1 w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-600 dark:bg-neutral-900"
|
||||
/>
|
||||
<ul class="max-h-48 overflow-y-auto">
|
||||
|
||||
@@ -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) {
|
||||
@@ -57,7 +67,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
<template>
|
||||
<BaseModal panel-class="w-full max-w-sm shadow-xl" @close="emit('close')">
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
|
||||
<h2 class="text-sm font-semibold">Manage labels</h2>
|
||||
<h2 class="text-sm font-semibold">Manage tags</h2>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
@@ -66,7 +76,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
<input
|
||||
v-model="newName"
|
||||
type="text"
|
||||
placeholder="Create label"
|
||||
placeholder="Create tag"
|
||||
class="flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800"
|
||||
/>
|
||||
</form>
|
||||
@@ -75,9 +85,9 @@ 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}`"
|
||||
aria-label="Change label color"
|
||||
:class="labelDot(lb)"
|
||||
:title="`Color: ${NOTE_COLOR_LABELS[resolveLabelColor(lb)]}`"
|
||||
aria-label="Change tag color"
|
||||
@click="openColor(lb.id)"
|
||||
/>
|
||||
<input
|
||||
@@ -94,8 +104,8 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
v-if="canMerge"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Merge into another label"
|
||||
aria-label="Merge into another label"
|
||||
title="Merge into another tag"
|
||||
aria-label="Merge into another tag"
|
||||
@click="openMerge(lb.id)"
|
||||
>
|
||||
<Icon name="merge" />
|
||||
@@ -103,8 +113,8 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Delete label"
|
||||
aria-label="Delete label"
|
||||
title="Delete tag"
|
||||
aria-label="Delete tag"
|
||||
@click="labels.remove(lb.id)"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
@@ -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>
|
||||
@@ -150,7 +160,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!labels.items.length" class="py-2 text-center text-xs text-neutral-400">
|
||||
No labels yet — create one above.
|
||||
No tags yet — create one above, or write a #tag in a note and it becomes one.
|
||||
</p>
|
||||
</div>
|
||||
</BaseModal>
|
||||
|
||||
@@ -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 { computed, nextTick, onBeforeUnmount, 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,24 +154,79 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- idle autosave ----
|
||||
//
|
||||
// This editor used to write ONLY on close, and the reason was cost: a body write
|
||||
// snapshotted a revision, so saving often meant a version history of thirty
|
||||
// snapshots of one paragraph being typed. The price was durability — a tab closed
|
||||
// mid-paragraph lost the paragraph, which is the one thing a notes app must not do.
|
||||
//
|
||||
// That trade is gone. Both engines now coalesce snapshots to one per editing
|
||||
// session (`revisions.py` and `store.rs`'s `should_snapshot`, Scribe #2971), so a
|
||||
// write costs a write. Writing on an idle pause is what collects the refund; the
|
||||
// Android editor already does the same.
|
||||
const AUTOSAVE_MS = 1000;
|
||||
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function cancelAutosave(): void {
|
||||
if (autosaveTimer !== null) {
|
||||
clearTimeout(autosaveTimer);
|
||||
autosaveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function autosave(): Promise<void> {
|
||||
// `flush` returns without writing while a save is in flight, which would silently
|
||||
// drop everything typed since that save began. Re-arming rather than skipping is
|
||||
// what keeps that from being a lost paragraph.
|
||||
if (saving.value) {
|
||||
scheduleAutosave();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await flush();
|
||||
} catch {
|
||||
// Swallowed on purpose. An autosave that interrupts typing with an error is
|
||||
// worse than one that waits for the next pause, and `close` still surfaces a
|
||||
// real failure at the moment the person is looking at the editor.
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutosave(): void {
|
||||
cancelAutosave();
|
||||
autosaveTimer = setTimeout(() => {
|
||||
autosaveTimer = null;
|
||||
void autosave();
|
||||
}, AUTOSAVE_MS);
|
||||
}
|
||||
|
||||
// EDIT mode only, deliberately. In compose, `dismiss` discards a note that was
|
||||
// never persisted, so that an accidental keystroke or a type-to-compose never
|
||||
// litters the board — and an autosave that created the row would take that away
|
||||
// without anyone asking for it. Materialising a compose on first keystroke is a
|
||||
// separate decision (Scribe #2967), not a side effect of this one.
|
||||
watch(body, () => {
|
||||
if (!isCreate.value) scheduleAutosave();
|
||||
});
|
||||
|
||||
onBeforeUnmount(cancelAutosave);
|
||||
|
||||
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 +237,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) ----
|
||||
//
|
||||
@@ -200,6 +282,10 @@ async function finish(): Promise<void> {
|
||||
|
||||
// Persist (create in compose, save in edit) and close the editor.
|
||||
async function close(): Promise<void> {
|
||||
// Cancelled first: a timer that fires during the leave animation would write
|
||||
// through a component on its way out, after `flush` has already saved the same
|
||||
// text.
|
||||
cancelAutosave();
|
||||
await flush();
|
||||
await finish();
|
||||
}
|
||||
@@ -208,6 +294,7 @@ async function close(): Promise<void> {
|
||||
// commit it explicitly (Done, Ctrl/Cmd+Enter, or Shift+Enter). An existing note, or a
|
||||
// compose already persisted by a rich action, closes normally (saving its text).
|
||||
async function dismiss(): Promise<void> {
|
||||
cancelAutosave();
|
||||
if (isCreate.value) {
|
||||
await finish();
|
||||
return;
|
||||
@@ -227,21 +314,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 +430,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 +521,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 +623,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 +766,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 +783,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"
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
|
||||
interface TauriGlobal {
|
||||
core: { invoke: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> };
|
||||
// Also from `withGlobalTauri`. Needed because quick capture puts the app in TWO
|
||||
// windows, each with its own Pinia stores — a note saved in one is invisible to
|
||||
// the other until something says so, and an event is the only channel between
|
||||
// them that does not involve polling SQLite.
|
||||
event?: {
|
||||
listen: <T>(event: string, handler: (e: { payload: T }) => void) => Promise<() => void>;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -192,3 +199,49 @@ export const updates = {
|
||||
*/
|
||||
install: () => invoke<void>("update_install"),
|
||||
};
|
||||
|
||||
|
||||
// --- Quick capture (#1899) ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* The stored hotkey and whether the OS actually accepted it.
|
||||
*
|
||||
* They disagree more often than you would like: a combination can be saved and
|
||||
* refuse to register because a window manager or another app already holds it,
|
||||
* and on Wayland a compositor may refuse global grabs entirely. `registered:
|
||||
* false` alongside a non-empty `shortcut` is precisely that case, and the UI has
|
||||
* to say so — a hotkey that silently does nothing is worse than none, because
|
||||
* there is nothing to look at and nothing to fix.
|
||||
*/
|
||||
export interface CaptureShortcut {
|
||||
/** The stored combination, or "" when quick capture is off. */
|
||||
shortcut: string;
|
||||
registered: boolean;
|
||||
}
|
||||
|
||||
/** Offered as a starting point, never applied on the user's behalf. */
|
||||
export const SUGGESTED_CAPTURE_SHORTCUT = "CommandOrControl+Shift+N";
|
||||
|
||||
/** Fired at the main window after a capture is saved. */
|
||||
const CAPTURED_EVENT = "thoughtsync://captured";
|
||||
|
||||
export const capture = {
|
||||
shortcut: () => invoke<CaptureShortcut>("capture_shortcut_get"),
|
||||
/** Pass "" to turn quick capture off. Rejects if the system refuses it. */
|
||||
setShortcut: (shortcut: string) => invoke<CaptureShortcut>("capture_shortcut_set", { shortcut }),
|
||||
/** Hide the capture window; `saved` decides whether the board is told to reload. */
|
||||
done: (saved: boolean) => invoke<void>("capture_done", { saved }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Run `handler` whenever a note is captured in the other window.
|
||||
*
|
||||
* Returns an unlisten function, or a no-op on the web build and on any desktop
|
||||
* runtime that does not expose the event API — the board simply keeps showing
|
||||
* what it has until its next load, which is a stale list rather than a broken one.
|
||||
*/
|
||||
export async function onCaptured(handler: () => void): Promise<() => void> {
|
||||
const events = window.__TAURI__?.event;
|
||||
if (!events) return () => {};
|
||||
return events.listen(CAPTURED_EVENT, () => handler());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -24,6 +24,15 @@ const router = createRouter({
|
||||
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
||||
],
|
||||
},
|
||||
{
|
||||
// The quick-capture window (#1899). Its own route because it is its own
|
||||
// WINDOW — no shell, no nav, one field. Desktop only: there is no global
|
||||
// hotkey in a browser tab and nothing to summon it.
|
||||
path: "/capture",
|
||||
name: "capture",
|
||||
component: () => import("../views/CaptureView.vue"),
|
||||
meta: { requiresAuth: true, requiresDesktop: true },
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
@@ -89,6 +98,13 @@ router.beforeEach(async (to) => {
|
||||
if (to.meta.requiresDesktop && !isDesktop()) {
|
||||
return { name: "board" };
|
||||
}
|
||||
// The capture window is opened at `index.html?capture=1` rather than at
|
||||
// `/capture`, because the bundled assets are served as files and a path with no
|
||||
// file behind it 404s in the production build — it only routes under the dev
|
||||
// server. A query string survives that, and this is where it becomes a route.
|
||||
if (to.query.capture === "1" && to.name !== "capture") {
|
||||
return { name: "capture" };
|
||||
}
|
||||
// Deliberately NOT applied to /login and /register: bouncing those on desktop
|
||||
// would loop against the requiresAuth guard above the moment a session is
|
||||
// missing. Nothing on the desktop navigates to them any more (AppShell's sign-out
|
||||
|
||||
@@ -2,15 +2,38 @@ import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { repo } from "../adapters";
|
||||
|
||||
// The Android build this server can hand out. Absent — not null — when it has
|
||||
// none, so `v-if` on it is the whole test; see client_dist.py.
|
||||
export interface AndroidClient {
|
||||
// One client build this server can hand out. Platforms it holds nothing for are
|
||||
// ABSENT from the map rather than present-and-null, so a key test is the whole
|
||||
// question; see client_dist.py.
|
||||
//
|
||||
// Named for its twin in `core/src/sync/client.rs`, which deserializes the same
|
||||
// payload. That one is deliberately NARROWER — it only ever reads
|
||||
// `/api/client/android`, so its `version_code` is an `i64` and it declares none of
|
||||
// the fields below that Android does not use. Widening it to match this is not a
|
||||
// tidy-up: every phone in the field runs the current shape.
|
||||
export interface ClientRelease {
|
||||
// The table row's id — "android", "linux-deb", "linux-appimage", "windows".
|
||||
platform: string;
|
||||
// What a person calls it, named for the DISTRO rather than the package format
|
||||
// ("Debian / Ubuntu", not ".deb"). The server owns this wording so the five
|
||||
// labels cannot drift apart across the surfaces that show them.
|
||||
label: string;
|
||||
version: string;
|
||||
// What decides "is this newer". The name is for people and sorts like a string.
|
||||
version_code: number;
|
||||
//
|
||||
// Not one type across platforms, deliberately: Android's is an integer because
|
||||
// Android's own install gate compares one, and the desktop's is Tauri's semver
|
||||
// key `1.0.<minutes>`. Nothing in this app compares them — the union is here so
|
||||
// the shape is honest rather than to be read.
|
||||
version_code: number | string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
// A PATH, never an absolute URL — the client joins it to the server it is
|
||||
// already talking to.
|
||||
url: string;
|
||||
// Present only for the AppImage: the minisign signature the desktop updater
|
||||
// checks before replacing the running binary.
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface PublicConfig {
|
||||
@@ -20,7 +43,14 @@ export interface PublicConfig {
|
||||
enable_url_unfurl: boolean;
|
||||
// How many days a note survives in Trash before the server purges it. 0 = forever.
|
||||
trash_retention_days: number;
|
||||
android_client?: AndroidClient;
|
||||
// Every client this server holds, keyed by platform id. Absent on a server that
|
||||
// holds none, and absent on the desktop's own offline config — the Tauri build
|
||||
// answers `config_get` locally and has no clients to hand out.
|
||||
//
|
||||
// `/api/config` also carries `android_client`, which is NOT declared here: it
|
||||
// exists for phones in the field polling for their own update, not for this app,
|
||||
// and reading it here would be a second path to the same fact.
|
||||
clients?: Record<string, ClientRelease>;
|
||||
}
|
||||
|
||||
// Public, unauthenticated app config (site name, whether signups are open).
|
||||
@@ -33,9 +63,9 @@ export const useConfigStore = defineStore("config", () => {
|
||||
// unreachable — and 30 is a safer stand-in than 0, since claiming "kept forever"
|
||||
// when the server is actually purging is the wrong way to be wrong.
|
||||
const trashRetentionDays = ref(30);
|
||||
// Null until proven otherwise: a server with no APK, and an older server that
|
||||
// never had the field, both correctly show no download.
|
||||
const androidClient = ref<AndroidClient | null>(null);
|
||||
// Empty until proven otherwise: a server with no clients, and an older server
|
||||
// that never had the field, both correctly offer no downloads.
|
||||
const clients = ref<Record<string, ClientRelease>>({});
|
||||
const loaded = ref(false);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
@@ -47,7 +77,7 @@ export const useConfigStore = defineStore("config", () => {
|
||||
version.value = cfg.version;
|
||||
enableUrlUnfurl.value = cfg.enable_url_unfurl ?? true;
|
||||
trashRetentionDays.value = cfg.trash_retention_days ?? 30;
|
||||
androidClient.value = cfg.android_client ?? null;
|
||||
clients.value = cfg.clients ?? {};
|
||||
} catch {
|
||||
// Keep defaults if the config endpoint is unreachable.
|
||||
} finally {
|
||||
@@ -66,7 +96,7 @@ export const useConfigStore = defineStore("config", () => {
|
||||
version,
|
||||
enableUrlUnfurl,
|
||||
trashRetentionDays,
|
||||
androidClient,
|
||||
clients,
|
||||
loaded,
|
||||
load,
|
||||
reload,
|
||||
|
||||
@@ -33,7 +33,35 @@ export const useLabelsStore = defineStore("labels", () => {
|
||||
}
|
||||
|
||||
async function rename(id: string, name: string): Promise<void> {
|
||||
// Renaming onto a name another tag already holds MERGES the two — server-side
|
||||
// and in the local store, identically. Detected from the LIST rather than from
|
||||
// the response: the survivor is whichever row is older, so it may well be the
|
||||
// one we asked to rename, and an id that still matches proves nothing happened.
|
||||
const absorbing = items.value.find(
|
||||
(lb) => lb.id !== id && lb.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
if (absorbing) {
|
||||
// A merge cannot be undone by repeating it, and here it is reachable by a
|
||||
// typo in a text field — so it asks, the way deleting one does. The counts
|
||||
// are named because "40 notes" is the part that makes the consequence real.
|
||||
const mine = items.value.find((lb) => lb.id === id);
|
||||
const confirmed = window.confirm(
|
||||
`A tag called "${absorbing.name}" already exists.\n\n` +
|
||||
`Renaming will MERGE these two into one tag named "${name}", carrying ` +
|
||||
`every note from both (${mine?.count ?? 0} + ${absorbing.count ?? 0}). ` +
|
||||
"The notes are kept; one of the two tags stops existing, and that cannot " +
|
||||
"be undone.",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const updated = await repo.labels.rename(id, name);
|
||||
if (absorbing) {
|
||||
// One row is gone and the survivor's count grew, and this response carries no
|
||||
// count — reload rather than guess which of the two we are now holding.
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
const idx = items.value.findIndex((lb) => lb.id === id);
|
||||
// The single-label PATCH doesn't recompute the count — keep the one we have.
|
||||
if (idx >= 0) items.value[idx] = { ...updated, count: items.value[idx].count };
|
||||
@@ -53,7 +81,7 @@ export const useLabelsStore = defineStore("labels", () => {
|
||||
// notes themselves survive; only the membership goes, which is the part people
|
||||
// most need reassuring about.
|
||||
const label = items.value.find((lb) => lb.id === id);
|
||||
const subject = label ? `the label "${label.name}"` : "this label";
|
||||
const subject = label ? `the tag "${label.name}"` : "this tag";
|
||||
const confirmed = window.confirm(
|
||||
`Delete ${subject}?\n\n` +
|
||||
"It will be removed from every note that uses it, on every device you sync " +
|
||||
|
||||
@@ -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,
|
||||
|
||||
+29
-19
@@ -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.
|
||||
@@ -272,6 +253,35 @@ body {
|
||||
@apply inline-flex min-h-[2.25rem] items-center justify-center px-3;
|
||||
}
|
||||
}
|
||||
/* THE button shape — the ONE definition of it in the app.
|
||||
*
|
||||
* It lives here, in the components layer, rather than inside BaseButton.vue,
|
||||
* because not every button in this app is a <button>. A DOWNLOAD has to be an
|
||||
* anchor: these are 3-95 MB installers, only an <a> can carry an href, and the
|
||||
* browser's own download manager handles that transfer better than anything the
|
||||
* app would do by fetching to a blob. BaseButton cannot serve that case, and a
|
||||
* second copy of its class list for anchors is how a page ends up with two
|
||||
* kinds of primary button that drift apart.
|
||||
*
|
||||
* So: BaseButton.vue wears these, and so does any anchor that must read as a
|
||||
* button. Neither owns the look.
|
||||
*
|
||||
* The `disabled:` variants are NOT here on purpose — an anchor has no
|
||||
* :disabled. BaseButton adds them itself, which is exactly the split: shared
|
||||
* where it is shared, local where the element differs.
|
||||
*/
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm
|
||||
font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
|
||||
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
|
||||
dark:focus-visible:ring-offset-neutral-950;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800;
|
||||
}
|
||||
.nav-link {
|
||||
@apply flex items-center gap-2 rounded-lg px-3 py-2 font-medium text-neutral-600 transition
|
||||
hover:bg-neutral-200/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useDevicesStore } from "../stores/devices";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import BaseButton from "../components/BaseButton.vue";
|
||||
import BaseInput from "../components/BaseInput.vue";
|
||||
import ClientDownloads from "../components/ClientDownloads.vue";
|
||||
import Icon from "../components/Icon.vue";
|
||||
import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../desktop/bridge";
|
||||
|
||||
@@ -12,14 +13,10 @@ import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../
|
||||
// Android apps authenticate sync with a device bearer token issued here.
|
||||
const devices = useDevicesStore();
|
||||
const ui = useUiStore();
|
||||
// The Android build this server holds, if it holds one. Null on a server with no
|
||||
// APK — the card below is hidden rather than offering a download that 404s.
|
||||
// Loaded here rather than in ClientDownloads: this view already awaits it, and a
|
||||
// component that fetches its own config would race the one that does.
|
||||
const config = useConfigStore();
|
||||
|
||||
function readableSize(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
const error = ref("");
|
||||
const newName = ref("");
|
||||
const creating = ref(false);
|
||||
@@ -160,35 +157,9 @@ onMounted(() => {
|
||||
</BaseButton>
|
||||
</section>
|
||||
|
||||
<!-- The Android client this server hands out (hidden when it has none) -->
|
||||
<section
|
||||
v-if="config.androidClient"
|
||||
class="mb-6 flex items-center justify-between gap-4 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Android app</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">
|
||||
Version {{ config.androidClient.version }} ·
|
||||
{{ readableSize(config.androidClient.size) }} · served by this server, so it always
|
||||
speaks the same sync protocol.
|
||||
</p>
|
||||
</div>
|
||||
<!-- A plain anchor, not BaseButton and not a fetch: this is 55 MB, and the
|
||||
browser's own download manager handles it better than anything this app
|
||||
would do with a blob. Styled to match BaseButton's primary variant,
|
||||
which is a <button> and cannot carry an href. -->
|
||||
<a
|
||||
:href="config.androidClient.url"
|
||||
:download="`thoughtsync-${config.androidClient.version}.apk`"
|
||||
class="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-brand px-4 py-2.5
|
||||
text-sm font-semibold text-neutral-900 shadow-sm transition hover:bg-brand-600
|
||||
active:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
|
||||
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
|
||||
dark:focus-visible:ring-offset-neutral-950"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</section>
|
||||
<!-- Every client this server holds, the one that fits this machine on top.
|
||||
Hides itself when the server holds none. -->
|
||||
<ClientDownloads />
|
||||
|
||||
<!-- One-time token reveal -->
|
||||
<div
|
||||
|
||||
@@ -11,7 +11,7 @@ import EmptyState from "../components/EmptyState.vue";
|
||||
import FilterBar from "../components/FilterBar.vue";
|
||||
import NoteGrid from "../components/NoteGrid.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
import { isDesktop, sync as syncBridge } from "../desktop/bridge";
|
||||
import { isDesktop, onCaptured, sync as syncBridge } from "../desktop/bridge";
|
||||
|
||||
const notes = useNotesStore();
|
||||
const config = useConfigStore();
|
||||
@@ -191,7 +191,7 @@ const emptyState = computed(() => {
|
||||
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
|
||||
if (currentView.value === "archived")
|
||||
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
|
||||
if (currentLabel.value) return { title: "No notes with this label", subtitle: "Tag a note to see it here." };
|
||||
if (currentLabel.value) return { title: "No notes with this tag", subtitle: "Tag a note to see it here." };
|
||||
// Says what "no account" actually means for the notes about to be written here.
|
||||
// A new user otherwise has no way to tell whether this thing is storing their
|
||||
// thoughts locally, silently waiting for a login, or quietly sending them off.
|
||||
@@ -227,8 +227,21 @@ onMounted(() => {
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
// A note written in the quick-capture window lands in the same SQLite file but a
|
||||
// different Pinia store — this window has no way to know unless it is told.
|
||||
// Registered as a promise because the listener is set up asynchronously, and
|
||||
// unregistered on the way out so a board that has been navigated away from does
|
||||
// not keep reloading itself.
|
||||
let stopCaptureListener: (() => void) | null = null;
|
||||
onMounted(() => {
|
||||
void onCaptured(() => void reload()).then((stop) => {
|
||||
stopCaptureListener = stop;
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onBoardKey);
|
||||
stopCaptureListener?.();
|
||||
ui.boardCardFocused = false;
|
||||
});
|
||||
watch([currentView, currentLabel, facetKey], reload);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
// The quick-capture window: one field, and two ways out.
|
||||
//
|
||||
// This runs in a SECOND Tauri window, summoned by a global hotkey over whatever
|
||||
// the person was doing. Everything here is shaped by that: no shell, no nav, no
|
||||
// board — a window that arrives uninvited has to be finishable in one gesture and
|
||||
// leave nothing behind if it isn't.
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import { repo } from "../adapters";
|
||||
import { capture } from "../desktop/bridge";
|
||||
|
||||
const body = ref("");
|
||||
const field = ref<HTMLTextAreaElement | null>(null);
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
// Focused on arrival, and after a save. The whole feature is "press the keys and
|
||||
// start typing" — a window that needs a click first has not saved anyone a step.
|
||||
await nextTick();
|
||||
field.value?.focus();
|
||||
});
|
||||
|
||||
async function save() {
|
||||
const content = body.value.trim();
|
||||
// Nothing typed is not an error, it is a change of mind — the same reading the
|
||||
// board takes of tapping + and walking away.
|
||||
if (!content) {
|
||||
void capture.done(false);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await repo.notes.create({ body: content });
|
||||
body.value = "";
|
||||
await capture.done(true);
|
||||
} catch {
|
||||
// The window STAYS OPEN on failure, holding the text. Hiding it would throw
|
||||
// away the only copy of something the person just wrote, to report a problem
|
||||
// they could otherwise retry their way out of.
|
||||
error.value = "Couldn't save that. Your text is still here — try again.";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
// The text is deliberately KEPT. The window is hidden rather than destroyed, so
|
||||
// a capture interrupted by something more urgent is still there on the next
|
||||
// press — which is the behaviour that makes it safe to press Escape.
|
||||
void capture.done(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-screen w-screen flex-col gap-2 bg-neutral-50 p-3 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100"
|
||||
>
|
||||
<textarea
|
||||
ref="field"
|
||||
v-model="body"
|
||||
class="min-h-0 flex-1 resize-none rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
|
||||
placeholder="Write it down…"
|
||||
aria-label="New note"
|
||||
@keydown.esc.prevent="dismiss"
|
||||
@keydown.enter.ctrl.prevent="save"
|
||||
@keydown.enter.meta.prevent="save"
|
||||
/>
|
||||
|
||||
<p v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<!-- The shortcuts are written down rather than assumed: this window is seen
|
||||
rarely and briefly, and it is the only place they are discoverable. -->
|
||||
<p class="text-xs text-neutral-400">
|
||||
<kbd>Ctrl</kbd>/<kbd>⌘</kbd> + <kbd>Enter</kbd> to save · <kbd>Esc</kbd> to dismiss
|
||||
</p>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button type="button" class="btn btn-ghost" @click="dismiss">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,6 +88,18 @@ async function submit() {
|
||||
>Create one</RouterLink
|
||||
>
|
||||
</p>
|
||||
|
||||
<!-- The build, on the one screen a person can reach WITHOUT an account.
|
||||
"I can't sign in" is a bug report like any other and it needs a build
|
||||
number; requiring a login to read one would withhold it from exactly the
|
||||
people who cannot get past this page. `/api/config` is public, so this
|
||||
costs nothing that was not already public (#3181). -->
|
||||
<p
|
||||
class="mt-8 select-all text-center text-[11px] text-neutral-400 dark:text-neutral-500"
|
||||
:title="`ThoughtSync server build ${config.version || 'unknown'}`"
|
||||
>
|
||||
{{ config.version || "unknown" }}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -5,8 +5,11 @@ import BaseButton from "../components/BaseButton.vue";
|
||||
import BaseInput from "../components/BaseInput.vue";
|
||||
import Icon from "../components/Icon.vue";
|
||||
import {
|
||||
SUGGESTED_CAPTURE_SHORTCUT,
|
||||
capture as captureBridge,
|
||||
sync as syncBridge,
|
||||
updates as updateBridge,
|
||||
type CaptureShortcut,
|
||||
type Compatibility,
|
||||
type ProbeResult,
|
||||
type RevokeOutcome,
|
||||
@@ -77,6 +80,31 @@ const checkedOnce = ref(false);
|
||||
|
||||
const updateAvailable = computed(() => !!update.value?.available);
|
||||
|
||||
// --- Quick capture -----------------------------------------------------------
|
||||
// A desktop-local preference, so it lives here beside the update channel rather
|
||||
// than in admin Settings: that screen is the SERVER's, and this is a property of
|
||||
// this installation on this machine.
|
||||
const shortcut = ref<CaptureShortcut>({ shortcut: "", registered: false });
|
||||
const shortcutDraft = ref("");
|
||||
const savingShortcut = ref(false);
|
||||
const shortcutError = ref("");
|
||||
|
||||
async function saveShortcut(value: string) {
|
||||
savingShortcut.value = true;
|
||||
shortcutError.value = "";
|
||||
try {
|
||||
shortcut.value = await captureBridge.setShortcut(value);
|
||||
shortcutDraft.value = shortcut.value.shortcut;
|
||||
} catch (e) {
|
||||
// The message comes from the core and names the actual reason — "something
|
||||
// else is already using it" reads very differently from "that is not a
|
||||
// shortcut this system understands", and both are things you can act on.
|
||||
shortcutError.value = String((e as { message?: string }).message ?? e);
|
||||
} finally {
|
||||
savingShortcut.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
checking.value = true;
|
||||
updateError.value = "";
|
||||
@@ -126,6 +154,13 @@ async function refresh() {
|
||||
// An older build without the update commands — leave the default showing
|
||||
// rather than blocking the whole Sync screen on it.
|
||||
}
|
||||
try {
|
||||
shortcut.value = await captureBridge.shortcut();
|
||||
shortcutDraft.value = shortcut.value.shortcut;
|
||||
} catch {
|
||||
// Older build without the capture commands. Same reading as the channel
|
||||
// above — show the default rather than block the screen.
|
||||
}
|
||||
try {
|
||||
status.value = await syncBridge.status();
|
||||
pending.value = await syncBridge.hasPending();
|
||||
@@ -452,6 +487,65 @@ onMounted(refresh);
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Quick capture. Outside the linked/unlinked split for the same reason as
|
||||
updates: a hotkey that writes to the local store needs no server. -->
|
||||
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
|
||||
<h2 class="text-sm font-semibold">Quick capture</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
A system-wide shortcut that opens a small window to write a note in, without
|
||||
bringing this one forward.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex items-end gap-3">
|
||||
<BaseInput
|
||||
id="capture-shortcut"
|
||||
v-model="shortcutDraft"
|
||||
label="Shortcut"
|
||||
:placeholder="SUGGESTED_CAPTURE_SHORTCUT"
|
||||
class="flex-1"
|
||||
/>
|
||||
<BaseButton :loading="savingShortcut" @click="saveShortcut(shortcutDraft)">Save</BaseButton>
|
||||
<BaseButton
|
||||
v-if="shortcut.shortcut"
|
||||
variant="ghost"
|
||||
:loading="savingShortcut"
|
||||
@click="saveShortcut('')"
|
||||
>
|
||||
Turn off
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<p v-if="shortcutError" class="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{{ shortcutError }}
|
||||
</p>
|
||||
|
||||
<!-- Stored and LIVE are reported separately because they can disagree: a
|
||||
combination another app grabbed first is saved here and does nothing when
|
||||
pressed, and saying only "your shortcut is X" would be a lie with a
|
||||
keystroke attached. -->
|
||||
<p
|
||||
v-else-if="shortcut.shortcut && !shortcut.registered"
|
||||
class="mt-2 text-sm text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{{ shortcut.shortcut }} is saved but isn't active — something else on this
|
||||
system is holding it. Try a different combination.
|
||||
</p>
|
||||
<p v-else-if="shortcut.registered" class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Press {{ shortcut.shortcut }} anywhere to capture a note.
|
||||
</p>
|
||||
<p v-else class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Off. There's no default on purpose — any combination picked for you is one
|
||||
taken away from something else on your machine.
|
||||
<button
|
||||
type="button"
|
||||
class="underline hover:text-neutral-700 dark:hover:text-neutral-300"
|
||||
@click="saveShortcut(SUGGESTED_CAPTURE_SHORTCUT)"
|
||||
>
|
||||
Use {{ SUGGESTED_CAPTURE_SHORTCUT }}
|
||||
</button>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Updates sit outside the linked/unlinked split on purpose: an install that
|
||||
has never touched a server still updates itself. -->
|
||||
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
|
||||
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# Collect every client this image should hand out, into one directory.
|
||||
#
|
||||
# fetch-clients.sh <dev|stable> <destdir>
|
||||
#
|
||||
# The server serves clients from `DATA_DIR/client/` or from the copy baked into the
|
||||
# image (`client_dist.py`). This is what fills the second one. It runs in CI, right
|
||||
# before `docker build`, and writes the FIXED filenames that module looks for.
|
||||
#
|
||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A `:dev` image serves dev clients;
|
||||
# `:latest` serves stable ones. Passed in rather than derived here, because the
|
||||
# caller is the thing that knows which image it is building.
|
||||
#
|
||||
# NEVER FAILS. A platform with nothing published means the server advertises
|
||||
# nothing for it and the UI hides that download — a supported state, and the only
|
||||
# one available before a platform's first build has ever published. Turning eight
|
||||
# fetches into eight ways to redden an otherwise fine lane would be strictly worse
|
||||
# than shipping an image that offers four clients instead of five.
|
||||
#
|
||||
# WHY THE VERSION IS FETCHED AND NOT DERIVED. The obvious shortcut is to run
|
||||
# `version.sh display desktop` here — this job has the checkout, after all. It is
|
||||
# wrong: this commit may not be the commit the channel is serving. A push touching
|
||||
# only `src/` does not rebuild the desktop, so the channel still holds an older
|
||||
# build, and a locally-derived version would describe those bytes with this
|
||||
# commit's number. The size check in `client_dist.py` would not catch it, because
|
||||
# the size IS measured from the real file — it would sail through and lie about the
|
||||
# version only. So the version comes from the channel, beside the bytes it
|
||||
# describes, and only `size`/`sha256` are measured here.
|
||||
set -eu
|
||||
|
||||
channel="${1:?usage: fetch-clients.sh <dev|stable> <destdir>}"
|
||||
dest="${2:?usage: fetch-clients.sh <dev|stable> <destdir>}"
|
||||
|
||||
case "$channel" in dev|stable) : ;; *)
|
||||
echo "fetch-clients.sh: unknown channel '$channel'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
|
||||
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}"
|
||||
BASE="$SERVER/$REPO/releases/download/$channel"
|
||||
|
||||
mkdir -p "$dest"
|
||||
|
||||
# Authenticated when we have a token — these releases are private (issue 2091), so
|
||||
# on this instance we always do. Anonymous still works against a public fork.
|
||||
fetch() {
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "$2" "$1"
|
||||
else
|
||||
curl -fsSL -o "$2" "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
# One field out of a small flat JSON object. `grep`/`sed` rather than a parser
|
||||
# because this runs in the CI image's busybox sh and adding a jq dependency to buy
|
||||
# one string is not a trade worth making. The sidecars are written by us and are
|
||||
# one level deep.
|
||||
field() {
|
||||
grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"?[^,\"}]+\"?" "$1" 2>/dev/null \
|
||||
| head -1 | sed -E 's/.*:[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/'
|
||||
}
|
||||
|
||||
bytes() { wc -c < "$1" | tr -d ' '; }
|
||||
digest() { sha256sum "$1" | cut -d' ' -f1; }
|
||||
|
||||
# The sidecar shape `client_dist.py` reads. `size` and `sha256` are measured from
|
||||
# the file that actually landed, so a truncated download cannot be described as a
|
||||
# whole one.
|
||||
sidecar() {
|
||||
_file="$1"; _out="$2"; _name="$3"; _code="$4"
|
||||
# `version_code` is QUOTED here, and that is not a slip. This function only ever
|
||||
# writes DESKTOP sidecars, whose ordering key is Tauri's `1.0.<minutes>` — which
|
||||
# unquoted is not valid JSON at all, so every sidecar this wrote would fail to
|
||||
# parse and the server would advertise nothing. Android's sidecar is a different
|
||||
# file, copied verbatim from its lane, and keeps its integer.
|
||||
printf '{\n "version_name": "%s",\n "version_code": "%s",\n "size": %s,\n "sha256": "%s"\n}\n' \
|
||||
"$_name" "$_code" "$(bytes "$_file")" "$(digest "$_file")" > "$_out"
|
||||
}
|
||||
|
||||
echo "==> Collecting the $channel clients"
|
||||
|
||||
# --- Android -----------------------------------------------------------------
|
||||
#
|
||||
# Its sidecar is published whole by the Android lane — an APK keeps its version in
|
||||
# a binary AXML manifest, so the values are recorded where they were already known.
|
||||
# Copied verbatim rather than rebuilt here.
|
||||
if fetch "$BASE/thoughtsync.apk" "$dest/thoughtsync.apk" &&
|
||||
fetch "$BASE/thoughtsync-android.json" "$dest/thoughtsync-android.json"; then
|
||||
echo " android $(field "$dest/thoughtsync-android.json" version_name)"
|
||||
else
|
||||
# Both or neither. Half a pair is worse than none: the server would read a
|
||||
# sidecar describing an APK that is not there, or an APK it cannot state a
|
||||
# version for.
|
||||
echo "::warning::No Android client on the $channel channel — this image ships without one."
|
||||
rm -f "$dest/thoughtsync.apk" "$dest/thoughtsync-android.json"
|
||||
fi
|
||||
|
||||
# --- desktop -----------------------------------------------------------------
|
||||
#
|
||||
# One sidecar on the channel carries the version PAIR for all four bundles, because
|
||||
# they are one build: `version_name` is what a person reads, `version_code` is the
|
||||
# ordering key, and the key is also what the bundle filenames are stamped with.
|
||||
# Written by `write-manifest.sh`, which is the step that speaks for what the channel
|
||||
# serves.
|
||||
bake_desktop() {
|
||||
desk="$dest/.desktop-release.json"
|
||||
if ! fetch "$BASE/thoughtsync-desktop.json" "$desk"; then
|
||||
echo "::warning::No desktop release on the $channel channel — this image ships without desktop clients."
|
||||
rm -f "$desk"
|
||||
return 0
|
||||
fi
|
||||
|
||||
name="$(field "$desk" version_name)"
|
||||
key="$(field "$desk" version_code)"
|
||||
rm -f "$desk"
|
||||
|
||||
if [ -z "$name" ] || [ -z "$key" ]; then
|
||||
echo "::warning::The $channel desktop sidecar named no version — skipping desktop clients."
|
||||
return 0
|
||||
fi
|
||||
echo " desktop $name (key $key)"
|
||||
|
||||
# Bundle filenames are stamped with the ORDERING KEY — what Tauri puts in them,
|
||||
# and what `write-manifest.sh` already selects on. Constructed rather than
|
||||
# discovered from the release's asset list: one shape, no JSON walk, and a name
|
||||
# that does not resolve is caught by the fetch failing rather than by matching
|
||||
# the wrong file.
|
||||
#
|
||||
# `<platform id>|<published name>|<name on disk>`
|
||||
for row in \
|
||||
"linux-deb|ThoughtSync_${key}_amd64.deb|thoughtsync.deb" \
|
||||
"linux-pacman|thoughtsync-${key}-1-x86_64.pkg.tar.zst|thoughtsync.pkg.tar.zst" \
|
||||
"linux-appimage|ThoughtSync_${key}_amd64.AppImage|thoughtsync.AppImage" \
|
||||
"windows|ThoughtSync_${key}_x64-setup.exe|thoughtsync-setup.exe"
|
||||
do
|
||||
id="${row%%|*}"; rest="${row#*|}"
|
||||
remote="${rest%%|*}"; local_name="${rest#*|}"
|
||||
|
||||
if ! fetch "$BASE/$remote" "$dest/$local_name"; then
|
||||
echo "::warning::$channel has no $remote — this image ships without the $id client."
|
||||
rm -f "$dest/$local_name"
|
||||
continue
|
||||
fi
|
||||
|
||||
# The AppImage is the only bundle that replaces itself in place, so the updater
|
||||
# verifies a signature before it does. Without one it is not servable as an
|
||||
# update, and `client_dist.py` treats it as absent rather than offering it
|
||||
# unverifiable — so drop the bundle too rather than baking 95 MB nothing can use.
|
||||
if [ "$id" = "linux-appimage" ]; then
|
||||
if ! fetch "$BASE/$remote.sig" "$dest/$local_name.sig"; then
|
||||
echo "::warning::$remote has no signature on $channel — dropping the AppImage."
|
||||
rm -f "$dest/$local_name" "$dest/$local_name.sig"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
sidecar "$dest/$local_name" "$dest/thoughtsync-$id.json" "$name" "$key"
|
||||
echo " $id $(bytes "$dest/$local_name") bytes"
|
||||
done
|
||||
}
|
||||
|
||||
bake_desktop
|
||||
|
||||
echo "==> Baked in:"
|
||||
ls -l "$dest"
|
||||
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,16 @@
|
||||
"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family)."""
|
||||
|
||||
# PACKAGING METADATA, and nothing else. Not the version any running server reports.
|
||||
#
|
||||
# A built image carries APP_VERSION in the environment, derived from the server's
|
||||
# own shipped file set (packaging/version.sh); `app.py` reads that and reports an
|
||||
# explicit "unknown" when it is absent, so this string never reaches a user and
|
||||
# bumping it changes nothing anybody sees.
|
||||
#
|
||||
# It exists because a Python package needs a version and "unknown" is not a legal
|
||||
# one here. It used to double as app.py's fallback, which meant a server run from a
|
||||
# checkout confidently reported `0.2.0` — a real-looking version naming no build
|
||||
# that exists. Note 3127 §5 is why that matters more than it reads: with version
|
||||
# tags gone, a build's self-report is the only answer to "which build is this?",
|
||||
# and there is nothing left to catch it lying.
|
||||
__version__ = "0.2.0"
|
||||
|
||||
+18
-5
@@ -11,7 +11,6 @@ from datetime import timedelta
|
||||
from quart import Quart, jsonify, send_from_directory
|
||||
from quart.sessions import SecureCookieSessionInterface
|
||||
|
||||
from . import __version__
|
||||
from .auth import bp as auth_bp
|
||||
from .client_dist import advertisement as client_advertisement, bp as client_bp
|
||||
from .config import Config
|
||||
@@ -64,7 +63,20 @@ def create_app() -> Quart:
|
||||
# Ephemeral/env secret so the app (and DB-free unit tests) construct without a
|
||||
# database. before_serving swaps in the real, DB-persisted key before serving.
|
||||
app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48)
|
||||
app.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__)
|
||||
# The RUNNING build, or an explicit "unknown" — never the packaging fallback.
|
||||
#
|
||||
# This read `os.environ.get("APP_VERSION", __version__)`, so a server started
|
||||
# from a checkout reported `0.2.0`: a real-looking version that names no build
|
||||
# anybody could get. `__init__.py` already claimed the honest answer was
|
||||
# "APP_VERSION being missing, which app.py already handles" — it did not, and a
|
||||
# comment asserting a behaviour two files away from the code is how that stayed
|
||||
# true-sounding for months.
|
||||
#
|
||||
# It matters more than it used to. Note 3127 §5 removed version tags, so this
|
||||
# string is the only answer to "which build is this?" and nothing exists to
|
||||
# contradict it when it is wrong. `__version__` stays where it belongs, as
|
||||
# packaging metadata, which is the one place "unknown" is not a legal value.
|
||||
app.config["APP_VERSION"] = os.environ.get("APP_VERSION") or "unknown"
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
# Auto-mark the session cookie Secure on HTTPS requests (see the interface above).
|
||||
@@ -192,9 +204,10 @@ def create_app() -> Quart:
|
||||
# linking — while it still has no token and possibly no account — to decide
|
||||
# whether it can talk to this server, and which optional features to offer.
|
||||
data.update(protocol_advertisement())
|
||||
# Which Android client this server can hand out, if any. Absent rather than
|
||||
# null when it has none, so the web UI hides the download instead of
|
||||
# offering a button that 404s.
|
||||
# Which CLIENTS this server can hand out, if any — the whole set under
|
||||
# `clients`, plus the older `android_client` key that phones in the field
|
||||
# still read. Absent rather than null when it has none, so the web UI hides
|
||||
# a download instead of offering a button that 404s.
|
||||
data.update(client_advertisement())
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user