Compare commits
16
Commits
142fafe0ed
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bcbea074e | ||
|
|
d1f8d1f561 | ||
|
|
06ed76814f | ||
|
|
b354bc4871 | ||
|
|
f16224b07c | ||
|
|
01b53118b9 | ||
|
|
0a631219ee | ||
|
|
715b648b95 | ||
|
|
a51a563fe7 | ||
|
|
5556bb2136 | ||
|
|
854cb4b403 | ||
|
|
19487b079a | ||
|
|
b6673c6420 | ||
|
|
6c5a45e195 | ||
|
|
1b3e29e4f6 | ||
|
|
867405fae2 |
+16
-31
@@ -9,44 +9,30 @@
|
|||||||
|
|
||||||
# --- required ---------------------------------------------------------------
|
# --- required ---------------------------------------------------------------
|
||||||
|
|
||||||
# Generate one and keep it: changing it later means also changing it inside the
|
# Generate one and keep it: changing it later means changing it inside the database
|
||||||
# database, or Postgres will reject the app's connection.
|
# too. Letters and digits only — it goes into a URL, where `@ / : #` break it.
|
||||||
#
|
#
|
||||||
# openssl rand -base64 24 | tr -d '/+=' | head -c 32
|
# openssl rand -base64 24 | tr -d '/+=' | head -c 32
|
||||||
#
|
|
||||||
# Stick to letters and digits. This value goes into a connection URL, so a `@`,
|
|
||||||
# `/`, `:` or `#` in it will be misparsed as URL structure rather than password.
|
|
||||||
POSTGRES_PASSWORD=
|
POSTGRES_PASSWORD=
|
||||||
|
|
||||||
# --- optional ---------------------------------------------------------------
|
# --- optional ---------------------------------------------------------------
|
||||||
|
|
||||||
# Which build to run.
|
# Which build: `latest` (the `main` branch, default), `dev` (newer, less settled),
|
||||||
#
|
# or a commit sha on `main` to pin one exact build — the rollback lever.
|
||||||
# latest tracks the `main` branch — the production line (default)
|
|
||||||
# dev tracks the `dev` branch — newer, less settled
|
|
||||||
# <commit sha> pins one exact build; every push publishes one, and this is
|
|
||||||
# the rollback lever when an upgrade misbehaves
|
|
||||||
#
|
|
||||||
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
|
|
||||||
# check which branch it actually landed on before assuming a bug.
|
|
||||||
#INKWELL_TAG=latest
|
#INKWELL_TAG=latest
|
||||||
|
|
||||||
# The host port the app is published on.
|
# The host port the app is published on.
|
||||||
#INKWELL_PORT=5000
|
#INKWELL_PORT=5000
|
||||||
|
|
||||||
# Which interface to bind. The default (all interfaces) is what lets desktop
|
# Which interface to bind. Behind a reverse proxy, set 127.0.0.1 so only the proxy
|
||||||
# clients on your network reach the server. Behind a reverse proxy, set this to
|
# can reach the app.
|
||||||
# 127.0.0.1 so only the proxy can talk to it.
|
|
||||||
#INKWELL_BIND=0.0.0.0
|
#INKWELL_BIND=0.0.0.0
|
||||||
|
|
||||||
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var —
|
# How many proxies sit in front is a setting, not an env var: Settings → Security →
|
||||||
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
|
# "Trusted proxy hops" (default 1).
|
||||||
# reverse proxy terminating HTTPS) and belongs there because it is something you may
|
|
||||||
# need to change while the server is running, alongside the sign-in limits.
|
|
||||||
|
|
||||||
# How much the app says. Credential events (sign-ins, failures, throttles, new
|
# How much the app logs. Credential events (sign-ins, failures, throttles, device
|
||||||
# accounts, device tokens issued) are logged at INFO and read with
|
# tokens issued) log at INFO; read them with `docker compose logs app`.
|
||||||
# `docker compose logs app`.
|
|
||||||
#INKWELL_LOG_LEVEL=INFO
|
#INKWELL_LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Database identity. Changing these AFTER the first start does not rename anything
|
# Database identity. Changing these AFTER the first start does not rename anything
|
||||||
@@ -70,14 +56,13 @@ POSTGRES_PASSWORD=
|
|||||||
#POSTGRES_USER=thoughtsync
|
#POSTGRES_USER=thoughtsync
|
||||||
#POSTGRES_DB=thoughtsync
|
#POSTGRES_DB=thoughtsync
|
||||||
#
|
#
|
||||||
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_* — the old
|
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_*; the old names
|
||||||
# names are no longer read.
|
# aren't read.
|
||||||
|
|
||||||
# --- a note on HTTPS --------------------------------------------------------
|
# --- a note on HTTPS --------------------------------------------------------
|
||||||
#
|
#
|
||||||
# The app marks its session cookie Secure automatically when a request arrives over
|
# The session cookie is marked Secure automatically when a request arrives over
|
||||||
# HTTPS, directly or via a proxy setting X-Forwarded-Proto — no setting needed.
|
# HTTPS, directly or via a proxy setting X-Forwarded-Proto.
|
||||||
#
|
#
|
||||||
# Worth knowing if you use the desktop app: typing a bare hostname there defaults to
|
# The desktop and Android apps read a bare hostname as https://, so a server on
|
||||||
# https://, deliberately, so a device token never crosses the wire in cleartext by
|
# plain HTTP has to be typed with its `http://`.
|
||||||
# accident. Serving over plain HTTP means typing the `http://` yourself.
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
name: Android
|
name: Android
|
||||||
|
|
||||||
# The native Kotlin/Compose client over the shared Rust core (M12).
|
# The native Kotlin/Compose client over the shared Rust core. The store and sync
|
||||||
#
|
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through uniffi.
|
||||||
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
|
|
||||||
# builds, not that Android has a lane: the UI is Compose, and the store and sync
|
|
||||||
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through
|
|
||||||
# uniffi.
|
|
||||||
#
|
#
|
||||||
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
|
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
|
||||||
# about feel, touch or on-device correctness is an operator pass on an emulator or
|
# about feel, touch or on-device correctness is an operator pass on an emulator or
|
||||||
@@ -15,12 +11,12 @@ name: Android
|
|||||||
# unsigned debug one when it is not. That distinction is not cosmetic: two builds
|
# unsigned debug one when it is not. That distinction is not cosmetic: two builds
|
||||||
# signed with different keys cannot replace one another, and bridging that gap
|
# signed with different keys cannot replace one another, and bridging that gap
|
||||||
# means uninstalling first — which deletes the app's database and every local note
|
# means uninstalling first — which deletes the app's database and every local note
|
||||||
# with it (Scribe issue 2803).
|
# with it.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
|
# 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.
|
# See desktop.yml for why.
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -32,15 +28,15 @@ env:
|
|||||||
# Silences the JDK 22+ "restricted method in java.lang.System has been called"
|
# Silences the JDK 22+ "restricted method in java.lang.System has been called"
|
||||||
# warning that Gradle 9.1's bundled native-platform jar trips at launch. This
|
# warning that Gradle 9.1's bundled native-platform jar trips at launch. This
|
||||||
# targets the LAUNCHER JVM, which is why org.gradle.jvmargs in
|
# targets the LAUNCHER JVM, which is why org.gradle.jvmargs in
|
||||||
# gradle.properties is not enough on its own (Minstrel hit the same thing).
|
# gradle.properties is not enough on its own.
|
||||||
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Does the APK need rebuilding, or is the channel already serving this source?
|
# 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
|
# See the equivalent job in desktop.yml — same reasoning, the same one file set in
|
||||||
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`.
|
# `packaging/version.sh`.
|
||||||
#
|
#
|
||||||
# The guard runs here so it covers the skip path too (§6.3).
|
# The guard runs here so it covers the skip path too.
|
||||||
#
|
#
|
||||||
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its server-image job dispatches
|
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its server-image job dispatches
|
||||||
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
|
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
|
||||||
@@ -74,12 +70,12 @@ jobs:
|
|||||||
|
|
||||||
# The core ships inside the APK (through android/ffi), so its checks have to gate
|
# The core ships inside the APK (through android/ffi), so its checks have to gate
|
||||||
# the APK HERE, in this workflow's graph. desktop.yml runs them too, but a red run
|
# the APK HERE, in this workflow's graph. desktop.yml runs them too, but a red run
|
||||||
# there cannot stop this lane publishing (rule 177, #5237).
|
# there cannot stop this lane publishing.
|
||||||
#
|
#
|
||||||
# On ci-tauri, not ci-rust-android: these are host-target tests, and on Linux the
|
# On ci-tauri, not ci-rust-android: these are host-target tests, and on Linux the
|
||||||
# core's native-tls is OpenSSL, whose headers only ci-tauri carries. The Android
|
# core's native-tls is OpenSSL, whose headers only ci-tauri carries. The Android
|
||||||
# image has OpenSSL vendored for the Android targets alone (run 8663 failed at
|
# image has OpenSSL vendored for the Android targets alone, so `openssl-sys` fails
|
||||||
# `openssl-sys` trying this there). Same Rust pin, same Cargo.lock.
|
# there. Same Rust pin, same Cargo.lock.
|
||||||
rust:
|
rust:
|
||||||
name: Core and FFI clippy and tests
|
name: Core and FFI clippy and tests
|
||||||
needs: [decide]
|
needs: [decide]
|
||||||
@@ -100,11 +96,11 @@ jobs:
|
|||||||
name: Kotlin + Rust (APK)
|
name: Kotlin + Rust (APK)
|
||||||
needs: [decide, rust]
|
needs: [decide, rust]
|
||||||
if: needs.decide.outputs.build == 'true'
|
if: needs.decide.outputs.build == 'true'
|
||||||
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
|
# runs-on is only a scheduling label. flutter-ci is the
|
||||||
# proven-working label that can pull our container images.
|
# proven-working label that can pull our container images.
|
||||||
runs-on: flutter-ci
|
runs-on: flutter-ci
|
||||||
container:
|
container:
|
||||||
# The image repurposed from ci-tauri-android in M12 step 3: Rust + the four
|
# Rust + the four
|
||||||
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
|
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
|
||||||
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
||||||
|
|
||||||
@@ -119,7 +115,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# Derives a version, so it needs the whole history — see the note in
|
# 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).
|
# desktop.yml. Depth-1 is silently wrong here, not loudly broken.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Cache Gradle and Cargo
|
- name: Cache Gradle and Cargo
|
||||||
@@ -143,7 +139,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
run: |
|
run: |
|
||||||
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
|
# TWO CLOCKS, ON PURPOSE. The NAME answers "is this the
|
||||||
# same code?", so it comes from the COMMIT and a dev build and the main
|
# 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
|
# build of one commit read identically. The CODE answers "may this be
|
||||||
# installed over that?" and must be monotonic BY CONSTRUCTION, because
|
# installed over that?" and must be monotonic BY CONSTRUCTION, because
|
||||||
@@ -161,7 +157,7 @@ jobs:
|
|||||||
echo "label=release" >> $GITHUB_OUTPUT
|
echo "label=release" >> $GITHUB_OUTPUT
|
||||||
# An optimised .so for the APK people install. The cargoNdk task keeps
|
# An optimised .so for the APK people install. The cargoNdk task keeps
|
||||||
# the symbols uniffi reads its metadata from, which the release
|
# the symbols uniffi reads its metadata from, which the release
|
||||||
# profile would strip (run 4077, Scribe #2810).
|
# profile would strip.
|
||||||
echo "profile=release" >> $GITHUB_OUTPUT
|
echo "profile=release" >> $GITHUB_OUTPUT
|
||||||
echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT
|
echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT
|
||||||
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
|
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
|
||||||
@@ -190,7 +186,7 @@ jobs:
|
|||||||
run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||||
|
|
||||||
# The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both
|
# The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both
|
||||||
# (M12 step 3) precisely so this lane needs no second image, and going
|
# precisely so this lane needs no second image, and going
|
||||||
# through Gradle plugins would mean a second version of each tool resolved
|
# through Gradle plugins would mean a second version of each tool resolved
|
||||||
# at build time and kept in lockstep with the image's by hand.
|
# at build time and kept in lockstep with the image's by hand.
|
||||||
#
|
#
|
||||||
@@ -210,7 +206,7 @@ jobs:
|
|||||||
#
|
#
|
||||||
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks
|
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks
|
||||||
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
|
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
|
||||||
# not exist (run 4082). It costs one extra Kotlin compile and buys the
|
# not exist. It costs one extra Kotlin compile and buys the
|
||||||
# type-check on the debug variant, which is the one an emulator build
|
# type-check on the debug variant, which is the one an emulator build
|
||||||
# would use.
|
# would use.
|
||||||
run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||||
@@ -233,16 +229,15 @@ jobs:
|
|||||||
# simply refuses to install over the app already on the phone — a failure
|
# simply refuses to install over the app already on the phone — a failure
|
||||||
# that otherwise only shows up on the device, after the run is green.
|
# that otherwise only shows up on the device, after the run is green.
|
||||||
#
|
#
|
||||||
# And FAILS unless there is exactly one signer and it is the release key
|
# And FAILS unless there is exactly one signer and it is the release key.
|
||||||
# (family idea #5103, practice 3). Printing alone was not a check: a build
|
# Printing alone is not a check: a build signed with any other key would go on
|
||||||
# signed with any other key would have gone on to be staged and published,
|
# to be staged and published, and every phone would refuse it. The steps that
|
||||||
# and every phone would have refused it. The steps that publish come after
|
# publish come after this one in the same job, so a failure here stops them.
|
||||||
# this one in the same job, so a failure here stops them.
|
|
||||||
- name: Check the signing certificate
|
- name: Check the signing certificate
|
||||||
if: steps.build.outputs.keystore != ''
|
if: steps.build.outputs.keystore != ''
|
||||||
env:
|
env:
|
||||||
# The release certificate's SHA-256, as apksigner printed it for the
|
# The release certificate's SHA-256, as apksigner prints it for a signed
|
||||||
# signed APK in run 8753 (CN=Bryan Van Deusen, O=fabledsword). Every
|
# APK (CN=Bryan Van Deusen, O=fabledsword). Every
|
||||||
# installed copy carries this certificate. It only changes if the key
|
# installed copy carries this certificate. It only changes if the key
|
||||||
# does, and then every install has to be replaced by hand anyway.
|
# does, and then every install has to be replaced by hand anyway.
|
||||||
RELEASE_CERT_SHA256: 408a5835ea1627f329d9fba4f00712b096faf2d30624ec6f3a8b2b5f8bb15caa
|
RELEASE_CERT_SHA256: 408a5835ea1627f329d9fba4f00712b096faf2d30624ec6f3a8b2b5f8bb15caa
|
||||||
@@ -285,10 +280,9 @@ jobs:
|
|||||||
# publishing an unsigned APK would offer people something they cannot install
|
# publishing an unsigned APK would offer people something they cannot install
|
||||||
# over what they already have.
|
# over what they already have.
|
||||||
#
|
#
|
||||||
# `stable` from main is new in M314 step 3, and it is what lets the server image
|
# Publishing `stable` from main is what lets the server image bake in a client
|
||||||
# bake in a client that matches its own channel: a :latest image fetches the APK
|
# that matches its own channel: a :latest image fetches the APK from `stable`, a
|
||||||
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
|
# :dev image from `dev`.
|
||||||
# all and every image — stable included — baked in the dev one.
|
|
||||||
- name: Publish to the channel for this branch
|
- name: Publish to the channel for this branch
|
||||||
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
|
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
|
||||||
working-directory: .
|
working-directory: .
|
||||||
@@ -300,18 +294,17 @@ jobs:
|
|||||||
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||||
esac
|
esac
|
||||||
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||||
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
# (packaging/channel-tag.sh). A tag named `dev` would shadow the branch.
|
||||||
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||||
export RELEASE_TAG RELEASE_PRERELEASE
|
export RELEASE_TAG RELEASE_PRERELEASE
|
||||||
echo "Publishing the APK to the $RELEASE_TAG channel."
|
echo "Publishing the APK to the $RELEASE_TAG channel."
|
||||||
bash desktop/packaging/publish-release.sh
|
bash desktop/packaging/publish-release.sh
|
||||||
|
|
||||||
- name: Upload the APK
|
- name: Upload the APK
|
||||||
# Stock action: it works on this forge since the runner moved to
|
# Stock action: it works on this forge because gitea/runner 3.x edits
|
||||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
# upload-artifact's client-side GHES refusal out of the action bundle. Never
|
||||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
# @v3 — it reports success while Gitea serves artifacts back only through the
|
||||||
# success while Gitea serves artifacts back only through the v4 API, so the
|
# v4 API, so the upload is stored and invisible.
|
||||||
# upload is stored and invisible (Scribe 2270).
|
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
# The APK's variant, NOT the Cargo profile. They are the same word for
|
# The APK's variant, NOT the Cargo profile. They are the same word for
|
||||||
@@ -338,11 +331,8 @@ jobs:
|
|||||||
# from here — that is the other half of this.
|
# from here — that is the other half of this.
|
||||||
#
|
#
|
||||||
# No `always()`: a red core check or APK build means no image for this
|
# No `always()`: a red core check or APK build means no image for this
|
||||||
# commit (rule 177, #5419). This used to run regardless, on the reasoning that
|
# commit. Building regardless would publish a `:<sha>` holding a client that
|
||||||
# a broken Android lane shouldn't block server delivery, and the image would
|
# isn't from that commit. The previous `:dev` stays where it is; fix forward.
|
||||||
# bake in the previous client. Run 8839 is what that meant: `:<sha>` published
|
|
||||||
# for 0ecdbfe while its APK lane (8835) was red, holding a client that wasn't
|
|
||||||
# from that commit. The previous `:dev` stays where it is; fix forward.
|
|
||||||
#
|
#
|
||||||
# Not skipped on tags: every ref that builds an image needs the call, since
|
# Not skipped on tags: every ref that builds an image needs the call, since
|
||||||
# ci.yml stands down on these pushes and nothing else builds one.
|
# ci.yml stands down on these pushes and nothing else builds one.
|
||||||
|
|||||||
+49
-66
@@ -3,22 +3,21 @@
|
|||||||
# Push to dev: typecheck + lint + test + build :dev
|
# Push to dev: typecheck + lint + test + build :dev
|
||||||
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||||
#
|
#
|
||||||
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
|
# THAT IS THE COMPLETE TAG SET. No version-shaped image tag in any lane: nothing
|
||||||
# nothing pins one — verified by looking for a consumer, not for whether one is
|
# pins one, and the git release tag is a different object in a different system.
|
||||||
# imaginable — and the git release tag is a different object in a different system
|
# The image is addressed by CHANNEL or by COMMIT; the release by date.
|
||||||
# (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,
|
# 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,
|
# and a tag rebuilding that same source would re-push :<sha>, which a published tag
|
||||||
# which rule 145 forbids even when they match.
|
# never allows.
|
||||||
#
|
#
|
||||||
# main is the production line, so a merge moves :latest to its tip (family rule 46)
|
# main is the production line, so a merge moves :latest to its tip — no version
|
||||||
# — no version release required. :<sha> is the immutable rollback unit, and it is
|
# release required. :<sha> is the immutable rollback unit, and it is on main ONLY: a
|
||||||
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled,
|
# sha tag per dev push is a rollback target nobody pulls, accumulating forever, for
|
||||||
# accumulating forever, for a channel whose entire contract is that it moves.
|
# a channel whose entire contract is that it moves.
|
||||||
#
|
#
|
||||||
# Required secret (repo -> Settings -> Secrets -> Actions):
|
# Required secret (repo -> Settings -> Secrets -> Actions):
|
||||||
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
|
# REGISTRY_TOKEN -- forge PAT with write:packages scope
|
||||||
# The registry username is derived from github.repository_owner (public — it's in
|
# The registry username is derived from github.repository_owner (public — it's in
|
||||||
# the image path), so no REGISTRY_USER secret is needed.
|
# the image path), so no REGISTRY_USER secret is needed.
|
||||||
name: CI & Build
|
name: CI & Build
|
||||||
@@ -28,13 +27,12 @@ on:
|
|||||||
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
|
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
|
||||||
# the image ALWAYS builds. Two reasons:
|
# the image ALWAYS builds. Two reasons:
|
||||||
#
|
#
|
||||||
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any
|
# * Every push to `main` publishes a `:<sha>`, so any production commit is
|
||||||
# production commit is addressable. A path filter quietly broke that promise
|
# addressable. A path filter would break that for a docs-only merge: no
|
||||||
# for a docs-only merge: no trigger, no image, no sha tag for that commit.
|
# trigger, no image, no sha tag for that commit.
|
||||||
# * It is the artifact most exposed to base-image staleness (`python:3.12-slim`
|
# * 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
|
# 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
|
# picks those updates up.
|
||||||
# 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
|
# 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.
|
# for the clients, which is why THEY skip and this does not.
|
||||||
@@ -62,17 +60,17 @@ jobs:
|
|||||||
#
|
#
|
||||||
# A push touching the Android app runs BOTH workflows at once. Building here
|
# A push touching the Android app runs BOTH workflows at once. Building here
|
||||||
# would bake in the PREVIOUS client and then, when the new one landed, there
|
# would bake in the PREVIOUS client and then, when the new one landed, there
|
||||||
# would be no second build — `:<sha>` is the immutable rollback unit (rule 46)
|
# would be no second build — `:<sha>` is the immutable rollback unit
|
||||||
# and rebuilding it with different content would make it neither.
|
# and rebuilding it with different content would make it neither.
|
||||||
#
|
#
|
||||||
# So on such a push this workflow stands down, and the Android lane dispatches it
|
# So on such a push this workflow stands down, and the Android lane dispatches it
|
||||||
# when it is finished. Exactly one image per commit, containing the client from
|
# when it is finished. Exactly one image per commit, containing the client from
|
||||||
# that commit.
|
# that commit.
|
||||||
#
|
#
|
||||||
# The path list below MUST match android.yml's trigger. Two places holding one
|
# The path list below MUST match android's file set in packaging/version.sh. Two
|
||||||
# decision is the recurring failure in this repo (issues 2181-2183); it is here
|
# places holding one decision drift apart; it is here because this gate needs the
|
||||||
# because a workflow cannot read another's filters, and it is a `git diff` rather
|
# answer before the Android lane has decided, and it is a `git diff` rather than a
|
||||||
# than a config so at least it is inspectable in the log.
|
# config so at least it is inspectable in the log.
|
||||||
gate:
|
gate:
|
||||||
name: Build now, or wait for Android?
|
name: Build now, or wait for Android?
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
@@ -125,11 +123,10 @@ jobs:
|
|||||||
echo "Changed in this push:"
|
echo "Changed in this push:"
|
||||||
echo "$changed" | sed 's/^/ /'
|
echo "$changed" | sed 's/^/ /'
|
||||||
|
|
||||||
# MUST match android's file set in packaging/version.sh. `packaging/` was
|
# MUST match android's file set in packaging/version.sh. A path missing here
|
||||||
# missing here after step 4 added it there — so a packaging-only push had
|
# lets a push touching only it rebuild and dispatch from the Android lane
|
||||||
# the Android lane rebuild and dispatch while this gate ALSO let the image
|
# while this gate ALSO lets the image build: two images for one commit and,
|
||||||
# build, producing two images for one commit and, on main, a second push of
|
# on main, a second push of the same :<sha> with different bytes.
|
||||||
# the same :<sha> with different bytes. Rule 145's exact prohibition.
|
|
||||||
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
|
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "This push also changes the Android client. Standing down: the"
|
echo "This push also changes the Android client. Standing down: the"
|
||||||
@@ -145,8 +142,8 @@ jobs:
|
|||||||
|
|
||||||
# The web's whole verification: the type check, then its unit tests. Both in the
|
# The web's whole verification: the type check, then its unit tests. Both in the
|
||||||
# job `build` already needs, so a red web test blocks the image like any other
|
# job `build` already needs, so a red web test blocks the image like any other
|
||||||
# lane (rule 177). The unit tests arrived with #5166; before them the web copy of
|
# lane. Among other things they guard the web copy of the note grammar, which is
|
||||||
# the note grammar, mirrored in Rust and Python, was guarded by nothing.
|
# mirrored in Rust and Python.
|
||||||
typecheck:
|
typecheck:
|
||||||
name: Web typecheck and unit tests
|
name: Web typecheck and unit tests
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
@@ -203,17 +200,16 @@ jobs:
|
|||||||
# and runs in the job below.
|
# and runs in the job below.
|
||||||
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||||
|
|
||||||
# Real-Postgres lane (family rule 6). Until this existed, `alembic upgrade head` ran
|
# Real-Postgres lane: `alembic upgrade head` runs here before any deployment runs
|
||||||
# for the first time when the operator's container started — 26 revisions, none of
|
# it, and the schema the migrations build is checked against the models that read
|
||||||
# them ever executed by CI — and the schema the migrations build had never been
|
# it.
|
||||||
# checked against the models that read it.
|
|
||||||
#
|
#
|
||||||
# Gates the build, along with every other lane — see the `build` job's `needs`.
|
# 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
|
# Job key stays separator-free ("integration") with no `name:`. act_runner
|
||||||
# derives the service-container name from the truncated job display name, and the
|
# derives the service-container name from the truncated job display name, and the
|
||||||
# discovery step below filters `docker ps` by it. Service hostnames are not routable
|
# discovery step below filters `docker ps` by it. Service hostnames are not routable
|
||||||
# on this runner (rule 79), so the step resolves the container's bridge IP.
|
# on this runner, so the step resolves the container's bridge IP.
|
||||||
integration:
|
integration:
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
@@ -251,8 +247,8 @@ jobs:
|
|||||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||||
# Only THIS job's services. The runner's docker daemon is shared, so another
|
# Only THIS job's services. The runner's docker daemon is shared, so another
|
||||||
# repo's job can be running beside this one, and a job-name filter matches its
|
# repo's job can be running beside this one, and a job-name filter matches its
|
||||||
# containers too (Steward run 8358 and Inkwell run 8653 each took another repo's
|
# containers too, and would take another repo's Postgres. act names every
|
||||||
# Postgres). act names every container of a task GITEA-ACTIONS-TASK-<n>-...: read
|
# container of a task GITEA-ACTIONS-TASK-<n>-...: read
|
||||||
# <n> from our own container (its id is in the /etc/hostname bind mount) and
|
# <n> from our own container (its id is in the /etc/hostname bind mount) and
|
||||||
# require exactly one match.
|
# require exactly one match.
|
||||||
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
|
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
|
||||||
@@ -264,7 +260,7 @@ jobs:
|
|||||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||||
test -n "$PG_IP"
|
test -n "$PG_IP"
|
||||||
export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
|
export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
|
||||||
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
|
# Wait for Postgres to accept connections. `run:` is busybox sh —
|
||||||
# no bash /dev/tcp — so use the Python that is always present here.
|
# no bash /dev/tcp — so use the Python that is always present here.
|
||||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||||
import socket, sys, time
|
import socket, sys, time
|
||||||
@@ -277,7 +273,7 @@ jobs:
|
|||||||
else:
|
else:
|
||||||
sys.exit("postgres did not become reachable")
|
sys.exit("postgres did not become reachable")
|
||||||
PY
|
PY
|
||||||
# Real migrations build the schema, never metadata.create_all (rule 82) —
|
# Real migrations build the schema, never metadata.create_all —
|
||||||
# testing a schema no deployment has ever seen would prove nothing. This
|
# testing a schema no deployment has ever seen would prove nothing. This
|
||||||
# step IS the migration test: a broken revision fails the job here.
|
# step IS the migration test: a broken revision fails the job here.
|
||||||
/opt/venv/bin/alembic upgrade head
|
/opt/venv/bin/alembic upgrade head
|
||||||
@@ -285,15 +281,9 @@ jobs:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build & push image
|
name: Build & push image
|
||||||
# Every lane gates the build. This once stopped at lint + typecheck, on the
|
# Every lane gates the build. An image tag is the rollback substrate, and one
|
||||||
# reasoning that DB-backed testing happened manually against the dev image
|
# that can be published from a failing run is not a substrate you can roll back
|
||||||
# rather than on every push — true until 6f21db8 added the integration lane,
|
# TO.
|
||||||
# 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]
|
needs: [gate, typecheck, lint, test, integration]
|
||||||
if: needs.gate.outputs.build == 'true'
|
if: needs.gate.outputs.build == 'true'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
@@ -306,22 +296,20 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
|
# 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).
|
# and produces a too-low value silently, with the lane green.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate image tags and version
|
- name: Generate image tags and version
|
||||||
id: tags
|
id: tags
|
||||||
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
|
# run: steps execute under busybox sh, so use POSIX `case`,
|
||||||
# NOT bash `[[ ]]`.
|
# NOT bash `[[ ]]`.
|
||||||
run: |
|
run: |
|
||||||
# The image's version is DERIVED from its own shipped files — including the
|
# 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
|
# 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
|
# it. One value and no ordering key: nothing compares a server image.
|
||||||
# §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
|
# Date-shaped, because "how old is this instance?" is the question that
|
||||||
# not answer "how old is this instance?" — the question that actually gets
|
# actually gets asked of a self-hosted app running in several places.
|
||||||
# asked of a self-hosted app running in several places.
|
|
||||||
BUILD_VERSION="$(sh packaging/version.sh display server)"
|
BUILD_VERSION="$(sh packaging/version.sh display server)"
|
||||||
case "${{ github.ref }}" in
|
case "${{ github.ref }}" in
|
||||||
refs/heads/dev)
|
refs/heads/dev)
|
||||||
@@ -344,17 +332,16 @@ jobs:
|
|||||||
docker builder prune --keep-storage 5g -f || true
|
docker builder prune --keep-storage 5g -f || true
|
||||||
|
|
||||||
# Bake EVERY client in, on every image build, so a self-hoster gets a working
|
# 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
|
# app for their machine from the server holding their notes, with no trip to a
|
||||||
# account on this forge, which is private (issue 2091) and is why serving them
|
# release page on the forge.
|
||||||
# from a release page was never an option for anybody but the operator.
|
|
||||||
#
|
#
|
||||||
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
|
# ~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
|
# the price of the product being complete, and the AppImage is not
|
||||||
# optional within it: it is the ONLY bundle that can replace itself in place,
|
# 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.
|
# so a server without one cannot serve in-app updates to anyone.
|
||||||
#
|
#
|
||||||
# Fetched by the JOB, not by the Dockerfile: the releases are private, and a
|
# Fetched by the JOB, not by the Dockerfile, so the token it may use never lands
|
||||||
# token used inside a build lands in the context or a layer.
|
# in the build context or a layer.
|
||||||
#
|
#
|
||||||
# NEVER fails the build — see the script. A platform with nothing published
|
# 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,
|
# means the server advertises nothing for it and the UI hides that download,
|
||||||
@@ -367,12 +354,8 @@ jobs:
|
|||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
|
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
|
||||||
# :latest serves stable ones. This read `download/dev` unconditionally
|
# :latest serves stable ones. The channel is chosen here rather than inside
|
||||||
# until M314 step 3, on every branch — so every stable server shipped a
|
# the script because the caller is what knows which image it is building.
|
||||||
# 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
|
case "${{ github.ref_name }}" in
|
||||||
main) channel=stable ;;
|
main) channel=stable ;;
|
||||||
*) channel=dev ;;
|
*) channel=dev ;;
|
||||||
|
|||||||
@@ -4,22 +4,20 @@
|
|||||||
# It DOES run on frontend changes. tauri's generate_context! embeds the built
|
# It DOES run on frontend changes. tauri's generate_context! embeds the built
|
||||||
# frontend in the binary, so a frontend commit that never triggers this ships to
|
# frontend in the binary, so a frontend commit that never triggers this ships to
|
||||||
# the web and silently never reaches the desktop app — and desktop, web and Android
|
# the web and silently never reaches the desktop app — and desktop, web and Android
|
||||||
# are peer surfaces held to one quality bar, not a primary and its fallbacks. The
|
# are peer surfaces held to one quality bar, not a primary and its fallbacks. A
|
||||||
# filter was once narrowed to the adapter/bridge directories against a "~20-40 min"
|
# build takes 4-5 minutes, so there is no cost worth narrowing that for.
|
||||||
# build; measured runs are 4-5 minutes, so the cost that justified the narrowing
|
|
||||||
# isn't there.
|
|
||||||
#
|
#
|
||||||
# Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli);
|
# Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli);
|
||||||
# runs-on is just a registered scheduling label (Label Model B), not a per-purpose
|
# runs-on is just a registered scheduling label, not a per-purpose
|
||||||
# runner. The frontend is built here because tauri's generate_context! embeds it.
|
# runner. The frontend is built here because tauri's generate_context! embeds it.
|
||||||
name: Desktop (Tauri)
|
name: Desktop (Tauri)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
# NO `paths:` FILTER. It was a second, independent statement of this artifact's
|
# NO `paths:` FILTER. It would be a second, hand-kept statement of this artifact's
|
||||||
# file set, hand-kept beside the one in `packaging/version.sh`, and it drifted
|
# file set beside the one in `packaging/version.sh`, and the two drift. The
|
||||||
# from it within a day (85ead4d). The `decide` job below reads the real set and
|
# `decide` job below reads the real set and skips in seconds when nothing moved —
|
||||||
# skips in seconds when nothing moved — one definition, one reader (§3).
|
# one definition, one reader.
|
||||||
#
|
#
|
||||||
# The cost is that this workflow starts on every push rather than on a matching
|
# 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
|
# one. That is a ~15s container for a decision, against a lane that cannot
|
||||||
@@ -40,13 +38,9 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
# Does anything need building at all?
|
# Does anything need building at all?
|
||||||
#
|
#
|
||||||
# ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing
|
# ONE reader of ONE definition: the file sets in `packaging/version.sh`.
|
||||||
# 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).
|
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one.
|
||||||
# Skipping because "the channel already serves this version" is indistinguishable
|
# Skipping because "the channel already serves this version" is indistinguishable
|
||||||
# from "we derived a stale value that happens to match" unless something checks.
|
# from "we derived a stale value that happens to match" unless something checks.
|
||||||
decide:
|
decide:
|
||||||
@@ -60,7 +54,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
# Derives a version — depth-1 is silently wrong (§6.1).
|
# Derives a version — depth-1 is silently wrong.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Decide
|
- name: Decide
|
||||||
@@ -76,13 +70,12 @@ jobs:
|
|||||||
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
|
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# The workspace checks, in their OWN job, so that BOTH publishing jobs can need
|
# The workspace checks, in their OWN job, so that BOTH publishing jobs can need
|
||||||
# them (rule 177: nothing publishes on red).
|
# them: nothing publishes on red.
|
||||||
#
|
#
|
||||||
# They used to be steps inside `build`, which gated the Linux publish and nothing
|
# As steps inside `build` they would gate the Linux publish and nothing else, and
|
||||||
# else. `windows` needed only `decide`, so on run 8411 clippy failed, the Linux job
|
# the Windows installer would publish beside a failed clippy. An edge from each
|
||||||
# stopped, and the Windows installer built and published to the dev release in the
|
# publisher to this job is the gate; a verdict inside a sibling job is only a
|
||||||
# same minute (#5184). An edge from each publisher to this job is the gate; a
|
# report.
|
||||||
# verdict inside a sibling job is only a report.
|
|
||||||
#
|
#
|
||||||
# Both publishers need it directly rather than through each other, so a Windows
|
# Both publishers need it directly rather than through each other, so a Windows
|
||||||
# failure still never blocks the Linux bundles, and neither waits on the other's
|
# failure still never blocks the Linux bundles, and neither waits on the other's
|
||||||
@@ -108,25 +101,20 @@ jobs:
|
|||||||
|
|
||||||
# The web's unit tests, HERE as well as in ci.yml. The installers embed this
|
# The web's unit tests, HERE as well as in ci.yml. The installers embed this
|
||||||
# frontend, and ci.yml's verdict is invisible to this workflow — run there only,
|
# frontend, and ci.yml's verdict is invisible to this workflow — run there only,
|
||||||
# a red web test would still let both installers publish (rule 177).
|
# a red web test would still let both installers publish.
|
||||||
- name: Web unit tests
|
- name: Web unit tests
|
||||||
run: npm test
|
run: npm test
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
# --locked on the FIRST cargo invocation is the lockfile gate: it fails the
|
# --locked on the FIRST cargo invocation is the lockfile gate: it fails the
|
||||||
# run if Cargo.toml and the committed Cargo.lock disagree, instead of silently
|
# run if Cargo.toml and the committed Cargo.lock disagree, instead of silently
|
||||||
# re-resolving (issue 2102). Both publishing jobs need this job, so neither
|
# re-resolving. Both publishing jobs need this job, so neither
|
||||||
# bundles a commit whose lockfile drifted.
|
# bundles a commit whose lockfile drifted.
|
||||||
#
|
#
|
||||||
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri.
|
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri. Inside the
|
||||||
#
|
# desktop crate, cargo scopes to the desktop PACKAGE: the core's tests would not
|
||||||
# These three steps used to run inside the desktop crate, which was right when
|
# run and nothing would lint the Android uniffi shim, while both still COMPILE
|
||||||
# it was the only Rust in the repo. After the core was extracted (M12 step 1)
|
# as dependencies — so the run stays green and the gap stays invisible.
|
||||||
# it silently stopped being right: cargo scoped to the desktop PACKAGE, so the
|
|
||||||
# core's 89 tests stopped running and nothing lints the Android uniffi shim at
|
|
||||||
# all. Both crates are dependencies of the desktop, so they still COMPILED —
|
|
||||||
# which is exactly why the gap was invisible, and why a green run kept meaning
|
|
||||||
# less than it looked like it meant.
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
@@ -148,8 +136,8 @@ jobs:
|
|||||||
name: Tauri desktop (Linux)
|
name: Tauri desktop (Linux)
|
||||||
needs: [decide, verify]
|
needs: [decide, verify]
|
||||||
# A deadline, not a budget: warm builds take ~4 minutes. Without one a hung step
|
# A deadline, not a budget: warm builds take ~4 minutes. Without one a hung step
|
||||||
# runs to the runner's own 30-minute cap, as run 8830 did on a rust-lld link whose
|
# runs to the runner's own 30-minute cap, as a rust-lld link whose threads have
|
||||||
# threads had all deadlocked (#5409). Kept under that cap so this one is the limit
|
# all deadlocked will. Kept under that cap so this one is the limit
|
||||||
# that fires, whatever the runner is configured with.
|
# that fires, whatever the runner is configured with.
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
if: needs.decide.outputs.build == 'true'
|
if: needs.decide.outputs.build == 'true'
|
||||||
@@ -166,7 +154,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
# 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
|
# 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
|
# the lane green — the direction you cannot recover
|
||||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||||
# emitting something plausible, which is what turns this into a red lane
|
# emitting something plausible, which is what turns this into a red lane
|
||||||
# if it is ever dropped.
|
# if it is ever dropped.
|
||||||
@@ -180,10 +168,10 @@ jobs:
|
|||||||
|
|
||||||
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
||||||
#
|
#
|
||||||
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
|
# createUpdaterArtifacts is applied only when a signing key exists: tauri
|
||||||
# tauri FAILS the build if it's asked to produce updater artifacts with no key,
|
# FAILS the build if it's asked to produce updater artifacts with no key, so
|
||||||
# so making it conditional is what lets the pipeline stay green before the
|
# making it conditional is what lets the pipeline stay green without the
|
||||||
# operator has added the secret. With the key present, each bundle gets a
|
# secret. With the key present, each bundle gets a
|
||||||
# `.sig` beside it — the file the updater actually verifies against.
|
# `.sig` beside it — the file the updater actually verifies against.
|
||||||
- name: Tauri build (deb + AppImage)
|
- name: Tauri build (deb + AppImage)
|
||||||
env:
|
env:
|
||||||
@@ -200,10 +188,10 @@ jobs:
|
|||||||
# The ORDERING KEY, not the display version: this string is what Tauri's
|
# 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
|
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||||
# `write-manifest.sh` then selects on. The human-readable version is a
|
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||||
# separate value and arrives with the UI that shows it (#3181).
|
# separate value, exported just below.
|
||||||
version="$(sh ../../packaging/version.sh key desktop)"
|
version="$(sh ../../packaging/version.sh key desktop)"
|
||||||
echo "Building desktop ordering key $version"
|
echo "Building desktop ordering key $version"
|
||||||
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
# The DISPLAY version, baked into the binary by `option_env!`.
|
||||||
# A different value for a different audience: this is the one a person
|
# 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
|
# 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
|
# sees. Exported rather than passed as a flag because the macro that
|
||||||
@@ -219,7 +207,7 @@ jobs:
|
|||||||
|
|
||||||
# Tauri's AppImage bundles the build host's graphics/display libs
|
# Tauri's AppImage bundles the build host's graphics/display libs
|
||||||
# (libEGL/libGL/libdrm/libgbm/libwayland-*), which clash with end-user GPU
|
# (libEGL/libGL/libdrm/libgbm/libwayland-*), which clash with end-user GPU
|
||||||
# drivers and abort to a black window (EGL_BAD_PARAMETER, issue 2021).
|
# drivers and abort to a black window (EGL_BAD_PARAMETER).
|
||||||
# Strip that host-coupled stack so the app uses the running system's
|
# Strip that host-coupled stack so the app uses the running system's
|
||||||
# graphics libs; webkit/gtk stay bundled. Runs from the repo root (the
|
# graphics libs; webkit/gtk stay bundled. Runs from the repo root (the
|
||||||
# script resolves its own paths), overwriting the AppImage in place.
|
# script resolves its own paths), overwriting the AppImage in place.
|
||||||
@@ -253,9 +241,9 @@ jobs:
|
|||||||
# control file and cross-checks it against what the ELF actually needs
|
# control file and cross-checks it against what the ELF actually needs
|
||||||
# (dpkg-shlibdeps).
|
# (dpkg-shlibdeps).
|
||||||
#
|
#
|
||||||
# We deliberately do NOT set bundle.linux.deb.depends: run 2872 showed
|
# We deliberately do NOT set bundle.linux.deb.depends: tauri already infers
|
||||||
# tauri already infers exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so
|
# exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so declaring them again only
|
||||||
# declaring them again only produced a control file listing each twice.
|
# produces a control file listing each twice.
|
||||||
# This step is the guard instead — if tauri's inference ever stops
|
# This step is the guard instead — if tauri's inference ever stops
|
||||||
# covering what the binary links, the build fails here.
|
# covering what the binary links, the build fails here.
|
||||||
- name: Verify the .deb
|
- name: Verify the .deb
|
||||||
@@ -271,13 +259,12 @@ jobs:
|
|||||||
run: bash desktop/packaging/arch/package-prebuilt.sh
|
run: bash desktop/packaging/arch/package-prebuilt.sh
|
||||||
|
|
||||||
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
|
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
|
||||||
# Stock action: it works on this forge since the runner moved to
|
# Stock action: it works on this forge because gitea/runner 3.x edits
|
||||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
# upload-artifact's client-side GHES refusal out of the action bundle. Never
|
||||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it uploads
|
# @v3 — it uploads something Gitea stores but will never serve back (it returns
|
||||||
# something Gitea stores but will never serve back (it returns artifacts only
|
# artifacts only through the v4 API, which filters on
|
||||||
# through the v4 API, which filters on content_encoding='application/zip').
|
# content_encoding='application/zip'). No continue-on-error: a swallowed upload
|
||||||
# No continue-on-error: a swallowed upload failure is exactly how 110
|
# failure lets unreachable artifacts pile up unnoticed. Fail loudly instead.
|
||||||
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
|
|
||||||
- name: Upload bundles
|
- name: Upload bundles
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
@@ -292,14 +279,11 @@ jobs:
|
|||||||
|
|
||||||
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
|
# 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
|
# 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"
|
# read — the forge has no /releases/latest/download/<asset> route, so "newest"
|
||||||
# cannot be named in a URL.
|
# cannot be named in a URL.
|
||||||
#
|
#
|
||||||
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
|
# MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
|
||||||
# M314 step 3 this job built on main and published nothing, so the stable
|
# reaches stable users with no tag cut.
|
||||||
# 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
|
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
||||||
# the secrets context isn't reliably available to step conditions. Publishing
|
# the secrets context isn't reliably available to step conditions. Publishing
|
||||||
@@ -315,7 +299,7 @@ jobs:
|
|||||||
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh.
|
||||||
# `prerelease` is true for dev so it does not read as a supported build,
|
# `prerelease` is true for dev so it does not read as a supported build,
|
||||||
# and false for stable, which is the real thing.
|
# and false for stable, which is the real thing.
|
||||||
case "$GITHUB_REF_NAME" in
|
case "$GITHUB_REF_NAME" in
|
||||||
@@ -323,7 +307,7 @@ jobs:
|
|||||||
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||||
esac
|
esac
|
||||||
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||||
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
# (packaging/channel-tag.sh). A tag named `dev` would shadow the branch.
|
||||||
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||||
export RELEASE_TAG RELEASE_PRERELEASE
|
export RELEASE_TAG RELEASE_PRERELEASE
|
||||||
echo "Publishing to the $RELEASE_TAG channel."
|
echo "Publishing to the $RELEASE_TAG channel."
|
||||||
@@ -355,7 +339,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
# 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
|
# 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
|
# the lane green — the direction you cannot recover
|
||||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||||
# emitting something plausible, which is what turns this into a red lane
|
# emitting something plausible, which is what turns this into a red lane
|
||||||
# if it is ever dropped.
|
# if it is ever dropped.
|
||||||
@@ -396,10 +380,10 @@ jobs:
|
|||||||
# The ORDERING KEY, not the display version: this string is what Tauri's
|
# 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
|
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||||
# `write-manifest.sh` then selects on. The human-readable version is a
|
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||||
# separate value and arrives with the UI that shows it (#3181).
|
# separate value, exported just below.
|
||||||
version="$(sh ../../packaging/version.sh key desktop)"
|
version="$(sh ../../packaging/version.sh key desktop)"
|
||||||
echo "Building desktop ordering key $version"
|
echo "Building desktop ordering key $version"
|
||||||
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
# The DISPLAY version, baked into the binary by `option_env!`.
|
||||||
# A different value for a different audience: this is the one a person
|
# 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
|
# 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
|
# sees. Exported rather than passed as a flag because the macro that
|
||||||
@@ -430,14 +414,11 @@ jobs:
|
|||||||
|
|
||||||
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
|
# 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
|
# 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"
|
# read — the forge has no /releases/latest/download/<asset> route, so "newest"
|
||||||
# cannot be named in a URL.
|
# cannot be named in a URL.
|
||||||
#
|
#
|
||||||
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
|
# MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
|
||||||
# M314 step 3 this job built on main and published nothing, so the stable
|
# reaches stable users with no tag cut.
|
||||||
# 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
|
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
||||||
# the secrets context isn't reliably available to step conditions. Publishing
|
# the secrets context isn't reliably available to step conditions. Publishing
|
||||||
@@ -453,7 +434,7 @@ jobs:
|
|||||||
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh.
|
||||||
# `prerelease` is true for dev so it does not read as a supported build,
|
# `prerelease` is true for dev so it does not read as a supported build,
|
||||||
# and false for stable, which is the real thing.
|
# and false for stable, which is the real thing.
|
||||||
case "$GITHUB_REF_NAME" in
|
case "$GITHUB_REF_NAME" in
|
||||||
@@ -461,7 +442,7 @@ jobs:
|
|||||||
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||||
esac
|
esac
|
||||||
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||||
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
# (packaging/channel-tag.sh). A tag named `dev` would shadow the branch.
|
||||||
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||||
export RELEASE_TAG RELEASE_PRERELEASE
|
export RELEASE_TAG RELEASE_PRERELEASE
|
||||||
echo "Publishing to the $RELEASE_TAG channel."
|
echo "Publishing to the $RELEASE_TAG channel."
|
||||||
@@ -488,7 +469,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
# 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
|
# 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
|
# the lane green — the direction you cannot recover
|
||||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||||
# emitting something plausible, which is what turns this into a red lane
|
# emitting something plausible, which is what turns this into a red lane
|
||||||
# if it is ever dropped.
|
# if it is ever dropped.
|
||||||
@@ -519,7 +500,7 @@ jobs:
|
|||||||
# holds the bundles, and the previous build's bundles are dropped once it
|
# 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.
|
# 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
|
# No tag arm: a `v*` tag does not reach this workflow at all — it
|
||||||
# triggers release.yml, which writes a changelog and builds nothing.
|
# triggers release.yml, which writes a changelog and builds nothing.
|
||||||
case "${GITHUB_REF_NAME}" in
|
case "${GITHUB_REF_NAME}" in
|
||||||
main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
|
main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7).
|
# A RELEASE BUILDS NOTHING. That is the whole point of this lane.
|
||||||
#
|
#
|
||||||
# The merge to `main` already published everything a user can receive: the server
|
# 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`
|
# 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
|
# 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
|
# source would produce identical artifacts under identical names, and would re-push
|
||||||
# `:<sha>` with different bytes — which rule 145 forbids even when they match.
|
# `:<sha>`, which a published tag never allows.
|
||||||
#
|
#
|
||||||
# So the tag is a BOOKMARK, and this lane gives it the only job it has left: saying
|
# 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
|
# what was in it. There are two halves to "what am I running", and
|
||||||
# the version answers only the first:
|
# the version answers only the first:
|
||||||
#
|
#
|
||||||
# which build is this? the footer, /api/config, the APK's versionName
|
# which build is this? the footer, /api/config, the APK's versionName
|
||||||
# what changed since the one ← this
|
# what changed since the one ← this
|
||||||
# I was running last month?
|
# I was running last month?
|
||||||
#
|
#
|
||||||
# Cutting the tag is the operator's act (rule 2). This only responds to one.
|
# This lane never cuts a tag; it only responds to one.
|
||||||
#
|
#
|
||||||
# THE TAG IS NOT AN IMAGE TAG and never becomes one. `ci.yml` does not trigger on
|
# 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.
|
# 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
|
# Same string as the artifact version (`vYYYY.MM.DD.HHMM`), different
|
||||||
# system.
|
# system.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
# Through publish-release.sh for its create-or-PATCH-on-409 path: a
|
# 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
|
# 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
|
# wrote, so re-tagging or re-running must rewrite it. No bundles
|
||||||
# exist in this workspace, so its asset globs match nothing and it
|
# exist in this workspace, so its asset globs match nothing and it
|
||||||
# uploads none — which is the intended behaviour, not a side effect.
|
# uploads none — which is the intended behaviour, not a side effect.
|
||||||
RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh
|
RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Rust workspace. The framework-free client core, and the two shims that wrap it:
|
# Rust workspace. The framework-free client core, and the two shims that wrap it:
|
||||||
# the Tauri desktop app and the uniffi bindings the native Android client loads.
|
# the Tauri desktop app and the uniffi bindings the native Android client loads.
|
||||||
# Neither shim owns the core — that is the reason it is a crate at all rather than a
|
# Neither shim owns the core — that is the reason it is a crate at all rather than a
|
||||||
# module inside the desktop app (Scribe note 2730).
|
# module inside the desktop app.
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"]
|
members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"]
|
||||||
|
|||||||
+7
-8
@@ -45,7 +45,7 @@ COPY client/ src/inkwell/client/
|
|||||||
|
|
||||||
# The desktop installer, served at /install.sh with this server's own address
|
# The desktop installer, served at /install.sh with this server's own address
|
||||||
# written into it (installer.py). The SAME script the forge serves raw, copied rather
|
# written into it (installer.py). The SAME script the forge serves raw, copied rather
|
||||||
# than moved so the operator's documented forge URL keeps working.
|
# than moved so the forge's raw URL keeps working.
|
||||||
COPY desktop/packaging/install.sh src/inkwell/install.sh
|
COPY desktop/packaging/install.sh src/inkwell/install.sh
|
||||||
|
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
@@ -55,16 +55,15 @@ ENV APP_VERSION=$BUILD_VERSION
|
|||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
|
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
|
||||||
# slow/unready database from crash-looping the container. Family convention
|
# slow or unready database from crash-looping the container. The schema is built by
|
||||||
# (rule 82): schema is built by real migrations, never metadata.create_all.
|
# real migrations, never metadata.create_all.
|
||||||
#
|
#
|
||||||
# Timeouts (family idea #5105, practice 9; read from hypercorn 0.18's source):
|
# Timeouts, as hypercorn 0.18 implements them:
|
||||||
# - `--keep-alive 120` is the idle timeout. It is also the header timeout:
|
# - `--keep-alive 120` is the idle timeout. It is also the header timeout:
|
||||||
# hypercorn marks a connection busy only once a whole request has arrived, so a
|
# hypercorn marks a connection busy only once a whole request has arrived, so a
|
||||||
# client dribbling headers is still "idle" and is cut off at this. It was 600,
|
# client dribbling headers is still "idle" and is cut off at this. 120 stays
|
||||||
# ten minutes per held connection. 120 stays above Traefik's 90s idle timeout
|
# above Traefik's 90s idle timeout for backend connections, so the proxy never
|
||||||
# for backend connections, so the proxy never reuses one this server has just
|
# reuses one this server has just closed (a 502).
|
||||||
# closed (a 502).
|
|
||||||
# - No `--read-timeout`, on purpose. It bounds every socket read, including the
|
# - No `--read-timeout`, on purpose. It bounds every socket read, including the
|
||||||
# whole time a download is streaming and the client sends nothing, so it would
|
# whole time a download is streaming and the client sends nothing, so it would
|
||||||
# cut off an APK fetched slowly over mobile data. Bodies are capped instead
|
# cut off an APK fetched slowly over mobile data. Bodies are capped instead
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -1,62 +1,21 @@
|
|||||||
# Inkwell
|
<p align="center">
|
||||||
|
<img src="frontend/public/icon.svg" width="96" alt="Inkwell logo">
|
||||||
|
</p>
|
||||||
|
|
||||||
Self-hosted personal thought-capture web app in the **FabledSword** family — a
|
<h1 align="center">Inkwell</h1>
|
||||||
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
|
|
||||||
under a second, designed to grow into a lightweight second brain (labels, search,
|
|
||||||
`[[wiki-links]]`, graph).
|
|
||||||
|
|
||||||
- **Stack:** Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI.
|
<p align="center">Capture a thought in under a second, and find it again later.</p>
|
||||||
- **Auth:** native email + password (signed-cookie sessions). Multi-user with the
|
|
||||||
family owner + direct-share + group-share ACL.
|
|
||||||
- **Web-first.** An Android companion is a later, conditional milestone.
|
|
||||||
|
|
||||||
## Layout
|
Inkwell is a self-hosted notes app built around a Keep-style board of cards. Type
|
||||||
|
and it's saved; tag with `#words`, tick checklists, set reminders, attach files,
|
||||||
|
and search or browse by date when you need something back. It runs in the browser,
|
||||||
|
as a desktop app for Linux and Windows that works offline, and as an Android app,
|
||||||
|
all syncing with your own server.
|
||||||
|
|
||||||
```
|
## Quick start
|
||||||
src/inkwell/ Quart app (app factory, auth, models, ACL, config, db)
|
|
||||||
alembic/ async migrations (schema built via `alembic upgrade head`)
|
|
||||||
tests/ DB-free unit tests (pytest)
|
|
||||||
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
|
|
||||||
Dockerfile 2-stage build (Vue -> python:3.12-slim runtime)
|
|
||||||
docker-compose.yml local app + Postgres stack
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
Save this as `docker-compose.yml`, replace both `CHANGE_ME`s with the same password,
|
||||||
|
and run `docker compose up -d`:
|
||||||
Backend (needs a Postgres reachable at `INKWELL_DATABASE_URL`):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pip install -e ".[dev]"
|
|
||||||
alembic upgrade head
|
|
||||||
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend (proxies `/api` to `:5000`):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev # http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
Or run it in Docker. **Hot-reload dev stack** (edit code, changes reload live) —
|
|
||||||
Postgres + backend + Vite dev server, app at http://localhost:5173:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose -f docker-compose.dev.yml up
|
|
||||||
```
|
|
||||||
|
|
||||||
Or the **full production image** (SPA baked in) at http://localhost:5000:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deploy (self-host)
|
|
||||||
|
|
||||||
A complete two-container stack (app + Postgres) you can copy and run. Save it as
|
|
||||||
`docker-compose.yml`, change the two `CHANGE_ME` passwords (they must match), then
|
|
||||||
`docker compose up -d`:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -65,26 +24,20 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: inkwell
|
POSTGRES_USER: inkwell
|
||||||
POSTGRES_PASSWORD: CHANGE_ME # change this
|
POSTGRES_PASSWORD: CHANGE_ME
|
||||||
POSTGRES_DB: inkwell
|
POSTGRES_DB: inkwell
|
||||||
volumes:
|
volumes:
|
||||||
- inkwell-db:/var/lib/postgresql/data
|
- inkwell-db:/var/lib/postgresql/data
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U inkwell"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: git.fabledsword.com/bvandeusen/inkwell:latest # :dev for the current dev build
|
image: git.fabledsword.com/bvandeusen/inkwell:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
- db
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
environment:
|
||||||
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
|
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
|
||||||
volumes:
|
volumes:
|
||||||
- inkwell-data:/var/inkwell # uploaded images; omit if you don't use attachments
|
- inkwell-data:/var/inkwell
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
|
|
||||||
@@ -93,36 +46,119 @@ volumes:
|
|||||||
inkwell-data:
|
inkwell-data:
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
|
Open `http://<host>:5000` and register. **The first account becomes the admin.**
|
||||||
|
|
||||||
- **Only `INKWELL_DATABASE_URL` is required.** `INKWELL_SECRET_KEY` is optional; if
|
The repository's own [docker-compose.yml](docker-compose.yml) is the same stack read
|
||||||
unset, a signing key is generated and persisted in the database (sessions survive restarts).
|
from a `.env` file; copy [.env.example](.env.example) to `.env` to use it.
|
||||||
- Uploaded images live under the `inkwell-data` volume at `/var/inkwell`.
|
|
||||||
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
|
|
||||||
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev`
|
|
||||||
build) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are
|
|
||||||
no version-shaped tags: nothing pins one, and the build reports its own version
|
|
||||||
at `/api/config` and `/health`.
|
|
||||||
- **Putting it on the public internet:** there are four things to do first — close
|
|
||||||
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
|
|
||||||
port, and back up the attachment volume as well as the database. See
|
|
||||||
[docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
|
|
||||||
hardens on its own and what it deliberately doesn't.
|
|
||||||
- **Install as an app (PWA):** Inkwell is installable ("Add to Home Screen" / the
|
|
||||||
browser's install button) for an app-like window. Browsers only offer install over a
|
|
||||||
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
|
|
||||||
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
|
|
||||||
|
|
||||||
## Milestones
|
## Configuration
|
||||||
|
|
||||||
- **M0 — Foundation & Identity** ✅: Quart+Vue+Postgres skeleton, native auth,
|
| Variable | Required | What it is |
|
||||||
sharing-ACL spine, Fabled-Git CI.
|
|---|---|---|
|
||||||
- **M1 — Capture Core** ✅: note model + masonry board (quick-add, colors, pin,
|
| `INKWELL_DATABASE_URL` | yes | The Postgres connection URL. |
|
||||||
edit-in-place, archive, trash+restore).
|
| `INKWELL_SECRET_KEY` | no | Signs sessions. Unset, one is generated and kept in the database. |
|
||||||
- **M1.5 — Roles & DB-backed Settings** ✅: first user is admin, admin Settings UI,
|
| `INKWELL_LOG_LEVEL` | no | `INFO` by default. Sign-ins and other credential events log at INFO. |
|
||||||
`DATABASE_URL` is the only required env.
|
|
||||||
- M2 — Organize: labels/tags, search, checklists, attachments.
|
|
||||||
- M3 — Second-brain seeds: `[[wiki-links]]`, backlinks, graph view, reminders.
|
|
||||||
- M4 — Launch hardening: responsive/PWA, settings UI, polish.
|
|
||||||
|
|
||||||
Work happens on `dev`; `main` is protected (PR-only).
|
Everything else — site name, sign-ups, email, attachment limits, trash retention,
|
||||||
|
link previews, backups, proxy hops — is set by the admin in **Settings**.
|
||||||
|
|
||||||
|
## Before you put it on the internet
|
||||||
|
|
||||||
|
Close registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing
|
||||||
|
the app port directly, and back up the attachment volume as well as the database.
|
||||||
|
[docs/public-hosting.md](docs/public-hosting.md) walks through each one, and lists
|
||||||
|
what the app already hardens on its own.
|
||||||
|
|
||||||
|
The browser only offers to install Inkwell as an app (PWA) over HTTPS or `localhost`.
|
||||||
|
|
||||||
|
## Updating and backups
|
||||||
|
|
||||||
|
- **Update:** `docker compose pull && docker compose up -d`. Migrations run on start.
|
||||||
|
- **Roll back:** pin `image: …/inkwell:<commit-sha>`. Every build on `main` publishes one.
|
||||||
|
- **Back up:** turn on scheduled backups in **Settings → Backups** (a folder, an
|
||||||
|
S3-compatible bucket, or Fabled Archives). See [docs/backups.md](docs/backups.md).
|
||||||
|
Otherwise back up both volumes: `inkwell-db` and `inkwell-data`.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
**Capturing**
|
||||||
|
- A masonry board of cards. Start typing anywhere on it to write a new note.
|
||||||
|
- Notes save as you type; there is no save button.
|
||||||
|
- Checklists inside a note, ticked straight from the card.
|
||||||
|
- Images and files attached to a note, shown on its card; click an image to see it
|
||||||
|
full size or copy it back out.
|
||||||
|
- Paste a link and it gets a preview card.
|
||||||
|
- On the desktop, a global hotkey opens a small capture window over whatever you're doing.
|
||||||
|
- On Android, share text or images into Inkwell from any app.
|
||||||
|
|
||||||
|
**Organising**
|
||||||
|
- `#tags` written in the text become tags; a line of tags on its own is tidied away.
|
||||||
|
- Each tag gets a colour, and tags can be renamed, recoloured, merged and deleted.
|
||||||
|
- Pin, archive, drag to reorder.
|
||||||
|
- Trash with undo, emptied after a retention period the admin sets.
|
||||||
|
|
||||||
|
**Finding**
|
||||||
|
- Full-text search, combined with filters for tags, attachments and notes shared with you.
|
||||||
|
- A timeline that groups notes by when you wrote them.
|
||||||
|
- A command palette that jumps to any note by name.
|
||||||
|
- Version history: every editing session keeps the note as it was before.
|
||||||
|
|
||||||
|
**Remembering**
|
||||||
|
- Reminders, one-off or repeating, with Done and snooze from the card or the
|
||||||
|
notification.
|
||||||
|
- The desktop and Android apps notify you when the app isn't in front.
|
||||||
|
|
||||||
|
**Sharing and accounts**
|
||||||
|
- Share a note with a person or a group, to view or to edit.
|
||||||
|
- Invites, groups, per-account storage limits and an activity log for the admin.
|
||||||
|
- Password reset by email, or by a link the admin makes.
|
||||||
|
- Export everything as a zip, and import an Inkwell export or a Google Keep Takeout.
|
||||||
|
|
||||||
|
**Every device**
|
||||||
|
- Web app, installable as a PWA.
|
||||||
|
- Desktop app for Linux (`.deb`, Arch package, AppImage) and Windows. It keeps its
|
||||||
|
own local store, works with no server at all, and syncs once you link one. Install
|
||||||
|
it from your server's Account page, or with `curl -fsSL https://<your-server>/install.sh | sh`.
|
||||||
|
- Android app, downloaded from your server and updated by it.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| What you see | Why | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| The app container restarts with a database error at startup | Postgres isn't up yet, or the two passwords differ | Wait a minute; check both `CHANGE_ME`s match |
|
||||||
|
| No "Install app" option in the browser | The page isn't on HTTPS | Serve it over HTTPS, or open it on `localhost` |
|
||||||
|
| The desktop or Android app can't reach a plain-HTTP server | A bare hostname is read as `https://` | Type the address with `http://` |
|
||||||
|
| A deployment upgraded from ThoughtSync shows no notes | It's pointed at new, empty volumes | Set `INKWELL_DB_VOLUME` and `INKWELL_DATA_VOLUME`; see [.env.example](.env.example) |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Backend (needs Postgres at `INKWELL_DATABASE_URL`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
alembic upgrade head
|
||||||
|
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend, proxying `/api` to `:5000`: `cd frontend && npm install && npm run dev`
|
||||||
|
(http://localhost:5173). Or run everything with hot reload:
|
||||||
|
`docker compose -f docker-compose.dev.yml up`.
|
||||||
|
|
||||||
|
| Path | What's there |
|
||||||
|
|---|---|
|
||||||
|
| `src/inkwell/` | The Quart server |
|
||||||
|
| `alembic/` | Database migrations |
|
||||||
|
| `frontend/` | The Vue web app, also the desktop app's UI |
|
||||||
|
| `core/` | The Rust store and sync engine the desktop and Android apps share |
|
||||||
|
| `desktop/` | The Tauri desktop app and its packaging |
|
||||||
|
| `android/` | The Kotlin/Compose Android app |
|
||||||
|
|
||||||
|
How devices sync is in [docs/sync.md](docs/sync.md); how the Android app reaches a
|
||||||
|
server is in [docs/android-distribution.md](docs/android-distribution.md). Work
|
||||||
|
happens on `dev`; `main` is the stable line.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Inkwell is free software under the [GNU Affero General Public License v3.0 only](LICENSE)
|
||||||
|
(`AGPL-3.0-only`). If you run a modified version for other people over a network, the
|
||||||
|
licence requires you to offer them its source.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ Revision ID: 0001
|
|||||||
Revises:
|
Revises:
|
||||||
Create Date: 2026-07-19
|
Create Date: 2026-07-19
|
||||||
|
|
||||||
The M0 identity + sharing-ACL spine. No notes yet (those arrive in M1); the
|
Identity and the sharing ACL. The shares table is polymorphic, so any shareable
|
||||||
shares table is polymorphic so notes reuse it without a schema retrofit.
|
entity uses it.
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Revision ID: 0002
|
|||||||
Revises: 0001
|
Revises: 0001
|
||||||
Create Date: 2026-07-19
|
Create Date: 2026-07-19
|
||||||
|
|
||||||
The M1 capture core: the notes table the masonry board renders.
|
The notes table the board renders.
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Revises: 0002
|
|||||||
Create Date: 2026-07-19
|
Create Date: 2026-07-19
|
||||||
|
|
||||||
Adds the admin role bit and the DB-backed settings table. No rows are seeded —
|
Adds the admin role bit and the DB-backed settings table. No rows are seeded —
|
||||||
defaults live in the code registry (thoughtsync.settings), so an empty table
|
defaults live in the code registry (inkwell.settings), so an empty table
|
||||||
means every setting is at its default.
|
means every setting is at its default.
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ depends_on = None
|
|||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# The note's display NAME: explicit title if set, else the first body line.
|
# The note's display NAME: explicit title if set, else the first body line.
|
||||||
op.add_column("notes", sa.Column("display_title", sa.Text(), nullable=False, server_default=""))
|
op.add_column("notes", sa.Column("display_title", sa.Text(), nullable=False, server_default=""))
|
||||||
# Best-effort backfill (kept intentionally simple + unambiguous — CI has no
|
# Best-effort backfill, capped at 200 chars. The app recomputes the exact value
|
||||||
# Postgres lane, so this only runs on a real deploy). The app recomputes the
|
# (derive_display_title, which also skips leading blank lines) on the next save.
|
||||||
# precise value via derive_display_title on the next save (which additionally
|
|
||||||
# skips leading blank lines). Capped at 200 chars.
|
|
||||||
op.execute(
|
op.execute(
|
||||||
r"""
|
r"""
|
||||||
UPDATE notes SET display_title = LEFT(btrim(
|
UPDATE notes SET display_title = LEFT(btrim(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""sync revision + tombstone infrastructure (M8 sync hub, step 1)
|
"""sync revision + tombstone infrastructure
|
||||||
|
|
||||||
Revision ID: 0015
|
Revision ID: 0015
|
||||||
Revises: 0014
|
Revises: 0014
|
||||||
@@ -11,9 +11,6 @@ forget to bump it. Child-row changes (items / attachments / label membership) bu
|
|||||||
their parent note's revision, since a note syncs as a whole. `purged_at` is the
|
their parent note's revision, since a note syncs as a whole. `purged_at` is the
|
||||||
hard-delete tombstone (distinct from `deleted_at` = recoverable trash) so an
|
hard-delete tombstone (distinct from `deleted_at` = recoverable trash) so an
|
||||||
offline client learns a row is gone instead of resurrecting it.
|
offline client learns a row is gone instead of resurrecting it.
|
||||||
|
|
||||||
No API behavior changes here — this is pure infrastructure. Triggers/behavior are
|
|
||||||
operator-verified on deploy (no Postgres CI lane).
|
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""device_tokens (M8 sync hub, step 2)
|
"""device_tokens
|
||||||
|
|
||||||
Revision ID: 0016
|
Revision ID: 0016
|
||||||
Revises: 0015
|
Revises: 0015
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""labels.updated_at (M8 sync hub, step 4 — LWW field for the label catalog)
|
"""labels.updated_at, the last-write-wins field for the label catalog
|
||||||
|
|
||||||
Revision ID: 0017
|
Revision ID: 0017
|
||||||
Revises: 0016
|
Revises: 0016
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""note_attachments.sha256 (M8 sync hub, step 5 — attachment blob sync)
|
"""note_attachments.sha256, for attachment blob sync
|
||||||
|
|
||||||
Revision ID: 0018
|
Revision ID: 0018
|
||||||
Revises: 0017
|
Revises: 0017
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""note_attachments.filename (any-file attachments — M6 1900)
|
"""note_attachments.filename, for attachments of any file type
|
||||||
|
|
||||||
Revision ID: 0019
|
Revision ID: 0019
|
||||||
Revises: 0018
|
Revises: 0018
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""note_link_previews (URL unfurl — M6 1901)
|
"""note_link_previews, cached URL unfurls
|
||||||
|
|
||||||
Revision ID: 0020
|
Revision ID: 0020
|
||||||
Revises: 0019
|
Revises: 0019
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""saved_filters (richer search + saved views — M6 1902)
|
"""saved_filters, saved search views
|
||||||
|
|
||||||
Revision ID: 0021
|
Revision ID: 0021
|
||||||
Revises: 0020
|
Revises: 0020
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""notes.recurrence (recurring reminders — M6 1908)
|
"""notes.recurrence, for recurring reminders
|
||||||
|
|
||||||
Revision ID: 0022
|
Revision ID: 0022
|
||||||
Revises: 0021
|
Revises: 0021
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1)
|
"""note_links.target_id — resolve [[links]] to a note, not to a string
|
||||||
|
|
||||||
Revision ID: 0023
|
Revision ID: 0023
|
||||||
Revises: 0022
|
Revises: 0022
|
||||||
Create Date: 2026-08-22
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename
|
A wiki-link stored only as normalized text makes a note's name the edge, so renaming
|
||||||
the note and every inbound link stops matching. The old answer was to rewrite the
|
the note breaks every inbound link. Resolving to an id survives a rename.
|
||||||
`[[Old Name]]` text inside every note that linked to it — workable while an explicit
|
|
||||||
title existed to hold still, untenable once a note's name is just its first body
|
|
||||||
line (M13).
|
|
||||||
|
|
||||||
`target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note
|
`target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note
|
||||||
that doesn't exist yet is a supported way to create one.
|
that doesn't exist yet is a supported way to create one.
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
"""drop note_links — [[wiki-links]] are removed (note 2897)
|
"""drop note_links — [[wiki-links]] are removed
|
||||||
|
|
||||||
Revision ID: 0024
|
Revision ID: 0024
|
||||||
Revises: 0023
|
Revises: 0023
|
||||||
Create Date: 2026-08-22
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
ThoughtSync is an intermediary surface for capture and recall; a linking system is
|
0023 stays in the chain: removing an applied revision would strand a database's
|
||||||
organization, which is not what it is for. Backlinks, the graph and the name index
|
alembic_version pointer.
|
||||||
went with it.
|
|
||||||
|
|
||||||
0023 (which added `note_links.target_id`) is deliberately left in the chain rather
|
note_links was derived from note bodies, so nothing typed is lost; the `[[text]]` is
|
||||||
than deleted. It shipped in an image and may already be applied, and removing an
|
still in every body.
|
||||||
applied revision would strand a database's alembic_version pointer. So the column is
|
|
||||||
dropped here along with the table it lived on, and the history stays honest about the
|
|
||||||
fact that it existed for a day.
|
|
||||||
|
|
||||||
No down-migration data concern: note_links was always DERIVED from note bodies. The
|
|
||||||
`[[text]]` is still sitting in every body it was written in; nothing a person typed is
|
|
||||||
lost by this.
|
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
"""drop notes.kind — a checklist is something a note HAS (M13 step 2)
|
"""drop notes.kind — a checklist is something a note has
|
||||||
|
|
||||||
Revision ID: 0025
|
Revision ID: 0025
|
||||||
Revises: 0024
|
Revises: 0024
|
||||||
Create Date: 2026-08-22
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared
|
A note is a body plus optional checkable items. Items are rows in their own table,
|
||||||
against a hardcoded ("text", "list") tuple in six places. `note_items` was always an
|
so a `kind = 'list'` note keeps every one.
|
||||||
ordinary child table keyed by note_id, serialization always emitted `items` whatever
|
|
||||||
the kind, and the Android editor already toggled between the two losslessly. The
|
|
||||||
storage has modelled "a body plus optional checkable items" the whole time; only the
|
|
||||||
gates forbade it.
|
|
||||||
|
|
||||||
Nothing is lost. Items were already rows in their own table, and a note that was
|
|
||||||
`kind = 'list'` keeps every one of them — it just stops being a different sort of
|
|
||||||
thing from the note next to it.
|
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -29,7 +21,6 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# server_default so existing rows get a value; every note comes back as 'text',
|
# server_default so existing rows get a value. Every note comes back as 'text':
|
||||||
# which is right — a restored note with items would previously have hidden its
|
# nothing records which were lists.
|
||||||
# body, and there is no record of which ones were once lists.
|
|
||||||
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text"))
|
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text"))
|
||||||
|
|||||||
@@ -4,33 +4,14 @@ Revision ID: 0026
|
|||||||
Revises: 0025
|
Revises: 0025
|
||||||
Create Date: 2026-08-22
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
M13 step 3. A note is a body plus optional checkable items; its NAME is the first
|
A note's name is its first non-empty body line, carried by `display_title`.
|
||||||
non-empty line of that body, falling back to its first checklist item. There is no
|
|
||||||
separate field to type into, and `display_title` (already persisted, already what
|
|
||||||
search results and export filenames read) carries the name.
|
|
||||||
|
|
||||||
## The search vector has to be rebuilt, not just left alone
|
`notes.search_vector` is a stored generated column over `title` (0005, weight A), and
|
||||||
|
Postgres won't drop a column a generated one depends on. So it is recreated over
|
||||||
|
`display_title`, keeping the name ranked above the body. That recomputes every row
|
||||||
|
and rebuilds the GIN index, which takes a while on a large table.
|
||||||
|
|
||||||
`notes.search_vector` is a STORED GENERATED column whose expression names `title`
|
`display_title` was already derived from the title, so every note keeps its name.
|
||||||
(migration 0005, weight A) — Postgres will refuse to drop a column another generated
|
|
||||||
column depends on, and even if it didn't, the weighting would be wrong. So it is
|
|
||||||
dropped and recreated over `display_title` instead, which keeps the original
|
|
||||||
intent: the note's NAME ranks above the rest of its body.
|
|
||||||
|
|
||||||
Rebuilding a stored generated column re-computes every row, and the GIN index is
|
|
||||||
rebuilt with it. On a personal instance that is milliseconds; it is worth knowing
|
|
||||||
before running this against something large.
|
|
||||||
|
|
||||||
## What happens to existing titles
|
|
||||||
|
|
||||||
Nothing preserves them, deliberately: `display_title` was already derived from the
|
|
||||||
title when one was set, so every note keeps the NAME it had. What is lost is the
|
|
||||||
distinction between "this note has an explicit title" and "this note's first line is
|
|
||||||
its name" — which is the distinction being removed.
|
|
||||||
|
|
||||||
Imports are the exception and are handled in code, not here: a Keep note's title, or
|
|
||||||
one in an export taken before this, is folded in as the note's first body line rather
|
|
||||||
than dropped (see `_create_imported_note`).
|
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -4,64 +4,31 @@ Revision ID: 0027
|
|||||||
Revises: 0026
|
Revises: 0026
|
||||||
Create Date: 2026-08-24
|
Create Date: 2026-08-24
|
||||||
|
|
||||||
M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items`
|
A checklist item becomes a `- [ ] milk` line of `notes.body`, so a list can sit
|
||||||
goes. The reason is positional, not cosmetic: a row had a position in a table and no
|
between two paragraphs, and `note_items` goes.
|
||||||
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
|
## This migration rewrites note bodies
|
||||||
|
|
||||||
Every note that has items gets its body appended to. The rules below are strict
|
* Rows are read before the table is dropped, in this one transaction.
|
||||||
because rewriting somebody's text deserves it — not, as an earlier draft of this
|
* The existing body is only appended to.
|
||||||
docstring claimed, because this instance holds imported Google Keep notes. It does
|
* The layout (a blank line between prose and the list, nothing between items) is
|
||||||
not; note 2916's headline is that nothing here is anyone's work but the operator's
|
what the export writes and what `derive::append_item` produces, so the clients
|
||||||
test data. What 2916 actually says about imports is conditional — text arriving from
|
migrate their own stores independently and still agree with the server.
|
||||||
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
|
The fold is inlined rather than imported from `notes/checklist.py`: a migration must
|
||||||
demands the day someone does import something:
|
keep producing what it produced the day it ran.
|
||||||
|
|
||||||
* Rows are read BEFORE the table is dropped, in this one transaction.
|
## `updated_at` is left alone
|
||||||
* 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
|
Raw SQL, so `onupdate` never fires, and a client holding an unpushed edit keeps the
|
||||||
|
newer `updated_at` and wins last-write-wins. The `sync_revision` trigger does fire,
|
||||||
|
so every migrated note is pulled once and clients converge.
|
||||||
|
|
||||||
`notes/checklist.py` has this same function and this migration deliberately does not
|
## The downgrade is not a true inverse
|
||||||
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
|
It recreates an empty `note_items` and leaves the bodies alone: nothing tells a line
|
||||||
|
this wrote from one somebody typed. The real rollback is a database restore. The
|
||||||
Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters
|
table must exist because 0015's downgrade drops a trigger on it.
|
||||||
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 re
|
||||||
|
|
||||||
@@ -100,9 +67,8 @@ def upgrade():
|
|||||||
|
|
||||||
for note_id, items in grouped.items():
|
for note_id, items in grouped.items():
|
||||||
body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar()
|
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
|
# An item whose note is gone has nothing to fold into. The foreign key should
|
||||||
# should make this impossible; skipping costs nothing and failing here would
|
# prevent it; skipping avoids a half-migrated database.
|
||||||
# leave the database half-migrated.
|
|
||||||
if body is None:
|
if body is None:
|
||||||
continue
|
continue
|
||||||
for text, checked in items:
|
for text, checked in items:
|
||||||
@@ -113,9 +79,8 @@ def upgrade():
|
|||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
# Column-for-column as migration 0006 created it, index name included: 0015's
|
# Exactly as 0006 created it, index name included: 0015's downgrade names both
|
||||||
# downgrade names both the table and its trigger, so a near-enough copy is not
|
# the table and its trigger.
|
||||||
# good enough.
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"note_items",
|
"note_items",
|
||||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
|||||||
@@ -4,73 +4,27 @@ Revision ID: 0028
|
|||||||
Revises: 0027
|
Revises: 0027
|
||||||
Create Date: 2026-08-26
|
Create Date: 2026-08-26
|
||||||
|
|
||||||
M311. A `#tag` was being shown twice — once as the text you typed and once as a chip —
|
A tag standing on its own line shows as a chip, so its text is removed, by the same
|
||||||
and with the chip moved to the top of the card the text is redundant. This removes it,
|
rule as `notes/tags.py:split_body_tags`:
|
||||||
but only from notes where the tag was standing on its own.
|
|
||||||
|
|
||||||
## This migration rewrites note bodies
|
* A line containing nothing but tags and whitespace is removed.
|
||||||
|
* Every other line is left exactly as written, so `remember to call #mom` stays.
|
||||||
|
* A line inside a ``` fence is never touched.
|
||||||
|
* A note that is nothing but tags keeps its text, rather than becoming a blank card.
|
||||||
|
|
||||||
The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags`
|
The label rows flip to `via_tag = false` in the same transaction: the text backing
|
||||||
applies from here on:
|
them is gone, and `_lift_and_reconcile_tags` would otherwise detach them on the next
|
||||||
|
save.
|
||||||
|
|
||||||
* A line containing nothing but tags and whitespace is REMOVED.
|
The transform and `_display_title` are inlined: a migration must keep producing what
|
||||||
* Every other line is left exactly as written.
|
it produced the day it ran. The title is recomputed only where the body moved.
|
||||||
|
|
||||||
So `#todo` on its own line goes, and `remember to call #mom tomorrow` does not. The
|
Raw SQL, so `onupdate` never fires and a client's unpushed edit still wins
|
||||||
looser reading — also stripping a trailing tag off a prose line — was rejected because
|
last-write-wins. The `sync_revision` trigger fires, so clients pull the server's text.
|
||||||
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:
|
The downgrade does nothing. Restoring the lines would be guessing, and flipping the
|
||||||
|
rows back to `via_tag = true` would detach every label on the next save. The real
|
||||||
* A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a
|
rollback is a database restore.
|
||||||
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 re
|
||||||
|
|
||||||
@@ -152,17 +106,11 @@ def upgrade():
|
|||||||
sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"),
|
sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"),
|
||||||
{"body": lifted, "title": _display_title(lifted), "id": note_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
|
# The flip is driven by the tag list, not by whether the text changed: an
|
||||||
# that its line was already removed by an earlier pass — so the flip is driven
|
# earlier pass may already have removed the line.
|
||||||
# by the tag list, not by whether the text changed.
|
|
||||||
if standalone:
|
if standalone:
|
||||||
bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]})
|
bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]})
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
"""Deliberately empty — see the module docstring.
|
"""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.
|
|
||||||
"""
|
|
||||||
|
|||||||
@@ -4,50 +4,18 @@ Revision ID: 0029
|
|||||||
Revises: 0028
|
Revises: 0028
|
||||||
Create Date: 2026-08-28
|
Create Date: 2026-08-28
|
||||||
|
|
||||||
M315 step 3. A note's colour was set by a picker and read by three card renderers.
|
Cards are one neutral surface per theme; the only colour on a board is a tag's.
|
||||||
Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and
|
`labels.color` is untouched. A colour a note was given is dropped with the column.
|
||||||
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
|
`saved_filters.params` may carry a `"color"` facet, so stored views are swept too. In
|
||||||
milestone was about keeping.
|
Python rather than `params::jsonb - 'color'`, because Postgres has no try-cast and one
|
||||||
|
malformed blob would abort the migration; a row that doesn't parse is left as it is.
|
||||||
|
|
||||||
## What is lost, and why that is the change rather than a cost of it
|
The search vector never named `color`, so it needs no rebuild.
|
||||||
|
|
||||||
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
|
## Downgrade
|
||||||
|
|
||||||
Restores the column, empty, at its old default. The values are not recoverable — see
|
Restores the column, empty, at its old default. The values are not recoverable.
|
||||||
above. It is the schema that comes back, not the data.
|
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -71,9 +39,8 @@ def upgrade() -> None:
|
|||||||
try:
|
try:
|
||||||
parsed = json.loads(params)
|
parsed = json.loads(params)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
# A blob that does not parse cannot be edited safely. Leaving it is
|
# A blob that doesn't parse can't be edited safely; the app couldn't read
|
||||||
# correct: it was already unreadable by the app, and this migration is not
|
# it either.
|
||||||
# the place to decide what it should have said.
|
|
||||||
continue
|
continue
|
||||||
if not isinstance(parsed, dict) or "color" not in parsed:
|
if not isinstance(parsed, dict) or "color" not in parsed:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -4,15 +4,9 @@ Revision ID: 0030
|
|||||||
Revises: 0029
|
Revises: 0029
|
||||||
Create Date: 2026-10-06
|
Create Date: 2026-10-06
|
||||||
|
|
||||||
The product is renamed ThoughtSync → Inkwell (Scribe note 5071), and the `site_name`
|
The app was renamed from ThoughtSync to Inkwell. The Settings page saves every key,
|
||||||
registry default moves with it. On its own that only reaches servers whose settings
|
so most servers have a stored `site_name` of the old default without anyone choosing
|
||||||
table has no `site_name` row — and most servers have one, because the Settings page
|
it. The row is rewritten only when it holds exactly that; a typed name is left alone.
|
||||||
saves EVERY key on Save, not just the one that changed. An admin who opened Settings
|
|
||||||
to flip registration off has persisted `"ThoughtSync"` without ever choosing it.
|
|
||||||
|
|
||||||
So the row is rewritten, but only when it holds exactly the old default. A name the
|
|
||||||
admin actually typed is theirs and is left alone; the old default is the one value we
|
|
||||||
can be sure nobody chose.
|
|
||||||
|
|
||||||
The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so
|
The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so
|
||||||
`'"ThoughtSync"'` is the whole of what it looks for.
|
`'"ThoughtSync"'` is the whole of what it looks for.
|
||||||
|
|||||||
@@ -4,16 +4,9 @@ Revision ID: 0031
|
|||||||
Revises: 0030
|
Revises: 0030
|
||||||
Create Date: 2026-10-07
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
0015 made every child table bump its parent note's `sync_revision`, so a note syncs
|
A note syncs as a whole, so every child table bumps its note's `sync_revision`. With
|
||||||
as a whole. `note_link_previews` arrived later (0020) and was never added, and two
|
this, a preview fetched after a save, or dismissed on the web, reaches every device.
|
||||||
things have been missing on every linked device since:
|
The trigger is the function 0015 installs on the other child tables.
|
||||||
|
|
||||||
- A preview fetched in the background after a save (`unfurl_queue.py`) never reached
|
|
||||||
a device that had already pulled the note. The note's revision was assigned when
|
|
||||||
the TEXT was saved, before the preview existed, and nothing moved it afterwards.
|
|
||||||
- A preview dismissed on the web stayed on every other device, for the same reason.
|
|
||||||
|
|
||||||
The trigger is the same function 0015 installs on the other child tables.
|
|
||||||
|
|
||||||
## Downgrade
|
## Downgrade
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ Revision ID: 0032
|
|||||||
Revises: 0031
|
Revises: 0031
|
||||||
Create Date: 2026-10-07
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
Until now the only way to add a second person was to re-open registration to the
|
An invite lets one person register while registration stays closed. Only the token's
|
||||||
whole internet while they signed up (#2939 §1). An invite lets one person register
|
SHA-256 hash is stored.
|
||||||
while registration stays closed. Only the token's SHA-256 hash is stored.
|
|
||||||
|
|
||||||
## Downgrade
|
## Downgrade
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,19 @@ Revision ID: 0033
|
|||||||
Revises: 0032
|
Revises: 0032
|
||||||
Create Date: 2026-10-07
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
A forgotten password used to need a hand on the database (#2939 §2), and the app has
|
An admin makes a reset link for the account and hands it over. Only the token's
|
||||||
no mail path to send a reset by. An admin makes a reset link for the account and
|
SHA-256 hash is stored.
|
||||||
hands it over (#5173). Only the token's SHA-256 hash is stored.
|
|
||||||
|
|
||||||
`users.session_epoch` is what lets a reset sign the account out everywhere. Sessions
|
`users.session_epoch` is what lets a reset sign the account out everywhere. Sessions
|
||||||
are signed cookies held by the browser, so the server can't delete them; each one
|
are signed cookies held by the browser, so the server can't delete them; each one
|
||||||
carries the epoch it was signed in under, and a reset moves the account's epoch on.
|
carries the epoch it was signed in under, and a reset moves the account's epoch on.
|
||||||
It starts at 0, the value a cookie from before this migration is read as, so nobody
|
It starts at 0, the value a cookie without one is read as, so the upgrade signs
|
||||||
is signed out by the upgrade itself.
|
nobody out.
|
||||||
|
|
||||||
## Downgrade
|
## Downgrade
|
||||||
|
|
||||||
Drops the table and the column. Outstanding reset links stop working; sessions keep
|
Drops the table and the column. Outstanding reset links stop working; sessions keep
|
||||||
working, since nothing checks an epoch any more.
|
working, since nothing checks an epoch.
|
||||||
"""
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ Revision ID: 0034
|
|||||||
Revises: 0033
|
Revises: 0033
|
||||||
Create Date: 2026-10-07
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
Nothing wrote a share until #5174, so the table never needed these. Now the Share
|
For the Share dialog:
|
||||||
dialog does:
|
|
||||||
- A unique index on (resource_type, resource_id, shared_with_user_id) where a user is
|
- A unique index on (resource_type, resource_id, shared_with_user_id) where a user is
|
||||||
the target, so sharing a note with someone twice updates the one grant rather than
|
the target, so sharing a note with someone twice updates the one grant rather than
|
||||||
stacking two (the same for a group, ahead of step 15).
|
stacking two (the same for a group).
|
||||||
- A check that `permission` is `view` or `edit`.
|
- A check that `permission` is `view` or `edit`.
|
||||||
- An index on `shared_with_user_id` for "Shared with me".
|
- An index on `shared_with_user_id` for "Shared with me".
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Revises: 0034
|
|||||||
Create Date: 2026-10-07
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
The change feed carries every note a person can see, including notes shared with
|
The change feed carries every note a person can see, including notes shared with
|
||||||
them (#5175). When a share ends, the note stops being visible, so it simply stops
|
them. When a share ends, the note stops being visible, so it simply stops
|
||||||
appearing in the feed. A device that already holds a copy would keep it forever. A
|
appearing in the feed. A device that already holds a copy would keep it forever. A
|
||||||
revocation is that missing signal: one row per (note, person) who lost the note,
|
revocation is that missing signal: one row per (note, person) who lost the note,
|
||||||
stamped from the same `sync_revision_seq` the notes and labels draw from, so it sits
|
stamped from the same `sync_revision_seq` the notes and labels draw from, so it sits
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ Revision ID: 0036
|
|||||||
Revises: 0035
|
Revises: 0035
|
||||||
Create Date: 2026-10-07
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
Pin, archive and board order are personal organization, and until now they were
|
Pin, archive and board order are personal. The owner's are columns on `notes`; this
|
||||||
columns on `notes`, so on a shared note they could only ever mean the owner's
|
table holds everyone else's, one row per (note, person it is shared with) who has
|
||||||
(#5176). This table holds them for everyone else: one row per (note, person it is
|
pinned, archived or moved it. A recipient with no row sees the note unpinned,
|
||||||
shared with) who has pinned, archived or moved it. The owner keeps the note's own
|
unarchived, in the owner's order.
|
||||||
columns; a recipient with no row sees the note unpinned, unarchived, in the owner's
|
|
||||||
order.
|
|
||||||
|
|
||||||
`sync_revision` is stamped by the same `ts_set_sync_revision()` trigger the notes
|
`sync_revision` is stamped by the same `ts_set_sync_revision()` trigger the notes
|
||||||
and labels use (0015), so a recipient's change reaches their own devices on the one
|
and labels use (0015), so a recipient's change reaches their own devices on the one
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ Revision ID: 0037
|
|||||||
Revises: 0036
|
Revises: 0036
|
||||||
Create Date: 2026-10-07
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
Both came with the foundation (0001) for features that were never built (#5178).
|
Nothing set `email_verified` or read `avatar_path`, and a field whose value means
|
||||||
Nothing ever set `email_verified`, so it was false for every account on the server
|
nothing would be trusted by a later feature.
|
||||||
while the offline desktop reported it as true; nothing read or wrote `avatar_path`.
|
|
||||||
A field whose value means nothing is worse than no field: a later feature would
|
|
||||||
trust it. If address verification is ever built, it adds its own column, with a
|
|
||||||
migration that says what the existing accounts are.
|
|
||||||
|
|
||||||
## Downgrade
|
## Downgrade
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ Revision ID: 0038
|
|||||||
Revises: 0037
|
Revises: 0037
|
||||||
Create Date: 2026-10-08
|
Create Date: 2026-10-08
|
||||||
|
|
||||||
Saved views were removed in #5180 (note 2897: recall first, organizing second). They
|
Saved views are removed; tags in the drawer give one-click recall.
|
||||||
lived only on the web; the desktop kept its own set that never synced, and Android
|
|
||||||
had none. Tags in the drawer already give one-click recall.
|
|
||||||
|
|
||||||
## Downgrade
|
## Downgrade
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ Revision ID: 0039
|
|||||||
Revises: 0038
|
Revises: 0038
|
||||||
Create Date: 2026-10-08
|
Create Date: 2026-10-08
|
||||||
|
|
||||||
Credential events were only written to the app log, which is not queryable and is
|
Keeps credential events in the database for `audit_retention_days`, for admins to
|
||||||
gone at the container's log rotation (#2939 §5). This keeps them in the database for
|
read in Settings → Activity; the app log isn't queryable and rotates away.
|
||||||
`audit_retention_days`, for admins to read in Settings → Activity.
|
|
||||||
|
|
||||||
## Downgrade
|
## Downgrade
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ Revision ID: 0040
|
|||||||
Revises: 0039
|
Revises: 0039
|
||||||
Create Date: 2026-10-08
|
Create Date: 2026-10-08
|
||||||
|
|
||||||
Deleting a tag now leaves a tombstone row (`purged_at` set) so the change feed can
|
Deleting a tag leaves a tombstone row (`purged_at` set) so the change feed tells
|
||||||
tell linked devices about it (#5382). Before, the web deleted the row outright and
|
linked devices. A tombstone must not hold its name, so `(owner_id, name)` is unique
|
||||||
devices never heard. A tombstone must not hold its name: creating `#grocery` again
|
only where `purged_at IS NULL`.
|
||||||
after deleting it has to make a live tag, on the web and from a device alike. So
|
|
||||||
`(owner_id, name)` stays unique only where `purged_at IS NULL`.
|
|
||||||
|
|
||||||
## Downgrade
|
## Downgrade
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,16 @@ plugins {
|
|||||||
val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
|
val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
|
||||||
|
|
||||||
// The ABIs a release APK carries. arm64 is essentially every real device; armv7
|
// The ABIs a release APK carries. arm64 is essentially every real device; armv7
|
||||||
// covers older 32-bit hardware; the two x86 targets are what emulators run on, and
|
// covers older 32-bit hardware; the two x86 targets are what emulators run, so the
|
||||||
// dropping them would make the app untestable on a desktop emulator (the reason
|
// app stays testable on a desktop emulator.
|
||||||
// x86_64 was added to the old Tauri lane in task 1864).
|
|
||||||
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting
|
* Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting
|
||||||
* `.so` into jniLibs, where AGP packages it.
|
* `.so` into jniLibs, where AGP packages it.
|
||||||
*
|
*
|
||||||
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
|
* `ExecOperations` injected rather than `project.exec`: Gradle 9 has no
|
||||||
* Gradle 9, and reaching for `project` at execution time is also what breaks the
|
* `project.exec`, and reaching for `project` at execution time breaks the
|
||||||
* configuration cache this build has enabled.
|
* configuration cache this build has enabled.
|
||||||
*/
|
*/
|
||||||
abstract class CargoNdkBuild : DefaultTask() {
|
abstract class CargoNdkBuild : DefaultTask() {
|
||||||
@@ -60,16 +59,15 @@ abstract class CargoNdkBuild : DefaultTask() {
|
|||||||
commandLine(listOf("cargo") + args)
|
commandLine(listOf("cargo") + args)
|
||||||
workingDir = workspaceDir.get().asFile
|
workingDir = workspaceDir.get().asFile
|
||||||
if (release) {
|
if (release) {
|
||||||
// The workspace's release profile with two of its settings put back,
|
// The workspace's release profile with two settings put back, for this
|
||||||
// for this build only. Environment overrides rather than a profile of
|
// build only. Environment overrides rather than a profile of our own,
|
||||||
// our own, because cargo-ndk copies its `-o` output from the
|
// because cargo-ndk copies its `-o` output from the `release`
|
||||||
// `release` directory and nothing says it understands any other one;
|
// directory; the desktop's binaries are untouched either way.
|
||||||
// the desktop's binaries are untouched either way.
|
|
||||||
//
|
//
|
||||||
// Keep the symbol table: `strip = true` removes the symbols uniffi's
|
// Keep the symbol table: `strip = true` removes the symbols uniffi's
|
||||||
// `--library` mode reads the interface out of, and bindgen then fails
|
// `--library` mode reads the interface out of, and bindgen then fails
|
||||||
// with "No UniFFI metadata found" (run 4077). There is no debug info
|
// with "No UniFFI metadata found". A release build has no debug info to
|
||||||
// in a release build to strip, so this keeps symbols and nothing else.
|
// strip, so this keeps symbols and nothing else.
|
||||||
environment("CARGO_PROFILE_RELEASE_STRIP", "debuginfo")
|
environment("CARGO_PROFILE_RELEASE_STRIP", "debuginfo")
|
||||||
// Unwind, so a panic in the core reaches Kotlin as an exception the
|
// Unwind, so a panic in the core reaches Kotlin as an exception the
|
||||||
// board can show rather than ending the app. See the ffi crate's docs.
|
// board can show rather than ending the app. See the ffi crate's docs.
|
||||||
@@ -141,17 +139,13 @@ val rustInputs =
|
|||||||
/**
|
/**
|
||||||
* Which Cargo profile the `.so` is built with.
|
* Which Cargo profile the `.so` is built with.
|
||||||
*
|
*
|
||||||
* A property rather than a debug/release task PAIR, deliberately. This runner has
|
* A property rather than a debug/release task pair: this runner has no working
|
||||||
* no working Gradle or Cargo cache (`reserveCache failed` on every run), so a cold
|
* Gradle or Cargo cache, so a cold cross-compile of four ABIs costs about four
|
||||||
* cross-compile of four ABIs costs about four minutes — and a lane that both
|
* minutes, and a lane that type-checks and packages with different profiles would
|
||||||
* type-checks and packages would pay that twice if the two used different
|
* pay it twice. `android.yml` picks one profile for every Gradle call in the run.
|
||||||
* profiles. `android.yml` picks one profile and uses it for every Gradle call in
|
|
||||||
* the run.
|
|
||||||
*
|
*
|
||||||
* CI passes `release` for a signed APK, so a phone runs an optimised store and sync
|
* CI passes `release` for a signed APK, so a phone runs an optimised store and sync
|
||||||
* engine. Until Scribe #2810 it passed `debug`, because the release profile's
|
* engine; `CargoNdkBuild` keeps the symbols bindgen needs.
|
||||||
* `strip = true` broke bindgen; `CargoNdkBuild` now keeps the symbols for this
|
|
||||||
* build alone.
|
|
||||||
*/
|
*/
|
||||||
val rustProfile =
|
val rustProfile =
|
||||||
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
||||||
@@ -187,8 +181,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.fabledsword.inkwell"
|
applicationId = "com.fabledsword.inkwell"
|
||||||
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
|
// 26 (Android 8, 2017) clears the NDK's floor with room to spare.
|
||||||
// room to spare.
|
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
// Injected by CI from the git tag + commit count for a release; "dev"
|
// Injected by CI from the git tag + commit count for a release; "dev"
|
||||||
@@ -212,11 +205,10 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The signing key reaches this build only through the environment: CI decodes
|
// The signing key reaches this build only through the environment: CI decodes
|
||||||
// it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path.
|
// it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path. It
|
||||||
// It is never in the repo and never in this file. Generated by the operator
|
// is never in the repo. An Android signing key cannot be rotated without the
|
||||||
// and never seen by an agent session, because an Android signing key cannot be
|
// original (v3 lineage needs it), so a leaked or lost one means every install
|
||||||
// rotated without the original — v3 lineage needs it — so a leaked or lost one
|
// has to be removed and replaced by hand.
|
||||||
// means every install has to be removed and replaced by hand.
|
|
||||||
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
|
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
|
||||||
val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() }
|
val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
@@ -225,13 +217,11 @@ android {
|
|||||||
create("release") {
|
create("release") {
|
||||||
storeFile = File(keystoreFile)
|
storeFile = File(keystoreFile)
|
||||||
storePassword = keystorePassword
|
storePassword = keystorePassword
|
||||||
// Hardcoded, and NOT a secret: the alias is fixed for the life of
|
// Hardcoded, and NOT a secret: the alias is fixed for the life of this
|
||||||
// this app and is written into the certificate every install
|
// app and written into the certificate every install carries. It says
|
||||||
// already carries. Hiding it would buy nothing and stop this file
|
// `thoughtsync`, the app's earlier name, because it names the key
|
||||||
// describing its own signing setup. It still says `thoughtsync`
|
// inside the existing keystore; renaming it would stop the key being
|
||||||
// after the rename to Inkwell, because it names the key inside the
|
// found.
|
||||||
// existing keystore, and renaming it would only stop that key being
|
|
||||||
// found. Same key, so the signing certificate did not change either.
|
|
||||||
keyAlias = "thoughtsync"
|
keyAlias = "thoughtsync"
|
||||||
// PKCS12 cannot hold a key password distinct from the store
|
// PKCS12 cannot hold a key password distinct from the store
|
||||||
// password — keytool refuses to set one — so this is the same
|
// password — keytool refuses to set one — so this is the same
|
||||||
|
|||||||
@@ -25,9 +25,8 @@
|
|||||||
FOREGROUND_SERVICE used only for expedited work; nothing here asks
|
FOREGROUND_SERVICE used only for expedited work; nothing here asks
|
||||||
for it, and it arrives with the library
|
for it, and it arrives with the library
|
||||||
|
|
||||||
Verified against the built APK's merged manifest, not assumed. Noted here
|
Listed because all four appear in the app's permission list and nothing
|
||||||
because all four appear in the app's permission list and nothing else in
|
else in this file would explain where they came from.
|
||||||
this file would explain where they came from.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -64,24 +63,22 @@
|
|||||||
refusing costs precision, not the feature — see Reminders.scheduleNext.
|
refusing costs precision, not the feature — see Reminders.scheduleNext.
|
||||||
|
|
||||||
RECEIVE_BOOT_COMPLETED already arrives via WorkManager (below), but is
|
RECEIVE_BOOT_COMPLETED already arrives via WorkManager (below), but is
|
||||||
declared here too because ReminderReceiver now depends on it directly. A
|
declared here too because ReminderReceiver depends on it directly. A
|
||||||
permission this file relies on should be visible in this file.
|
permission this file relies on should be visible in this file.
|
||||||
-->
|
-->
|
||||||
<!--
|
<!--
|
||||||
Updating this app from the server it syncs with (M12 step 7).
|
Updating this app from the server it syncs with.
|
||||||
|
|
||||||
REQUEST_INSTALL_PACKAGES lets the app hand an APK to the system installer at
|
REQUEST_INSTALL_PACKAGES lets the app hand an APK to the system installer at
|
||||||
all. It is NOT what makes an install look suspicious to on-device heuristics
|
all. It is NOT what makes an install look suspicious to on-device heuristics;
|
||||||
— Mihon declares it too — the legacy ACTION_VIEW install intent was, and this
|
the legacy ACTION_VIEW install intent is, and this app uses a PackageInstaller
|
||||||
app uses a PackageInstaller session instead. See AppUpdate.kt and Scribe note
|
session instead (AppUpdate.kt). The person must also grant "install unknown
|
||||||
2437. The person must additionally grant "install unknown apps" in system
|
apps" in system settings; the update card asks before downloading anything.
|
||||||
settings; the update card asks before downloading anything.
|
|
||||||
|
|
||||||
UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) is what removes the install
|
UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) removes the install confirmation
|
||||||
confirmation on the UPDATE path, and only there — Android will not let an app
|
on the UPDATE path only: Android will not let an app silently put a NEW package
|
||||||
silently put a NEW package on a device, which is correct. It also only applies
|
on a device. It applies only when the new build is signed with the same key as
|
||||||
when the new build is signed with the same key as the installed one, which is
|
the installed one.
|
||||||
why signing had to land before any of this could work.
|
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||||
@@ -91,12 +88,11 @@
|
|||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
allowBackup stays true, which family idea #5105 (practice 12) says to turn
|
allowBackup stays true. Inkwell is local-first, so an unlinked phone's notes
|
||||||
off. Inkwell is local-first, so an unlinked phone's notes exist only on the
|
exist only on the phone, and Android's backup is their one other copy. The
|
||||||
phone, and Android's backup is their one other copy. The device token is what
|
device token is stored sealed under a Keystore key that never leaves the phone
|
||||||
the practice protects, and it is stored sealed under a Keystore key that never
|
(KeystoreTokenSeal.kt), so a backup carries the notes and a token nothing can
|
||||||
leaves the phone (KeystoreTokenSeal.kt). A backup therefore carries the notes
|
open, and a restored app asks to sign in again.
|
||||||
and a token nothing can open, and a restored app asks to sign in again.
|
|
||||||
-->
|
-->
|
||||||
<application
|
<application
|
||||||
android:name=".InkwellApplication"
|
android:name=".InkwellApplication"
|
||||||
@@ -111,12 +107,11 @@
|
|||||||
launchMode="singleTop" exists for the SHARE filters below.
|
launchMode="singleTop" exists for the SHARE filters below.
|
||||||
|
|
||||||
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
|
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
|
||||||
intent, so onNewIntent already worked for that one. A share intent is
|
intent, but a share intent is built by the OTHER app (Chrome, a reader,
|
||||||
built by the OTHER app — Chrome, a reader, the text-selection toolbar —
|
the text-selection toolbar) and nothing here can add a flag to it.
|
||||||
and nothing here can add a flag to it. Without singleTop declared on the
|
Without singleTop on the activity, every share while the app is running
|
||||||
activity itself, every share while the app is running would stack a
|
would stack a second MainActivity: a second view model, a second board,
|
||||||
second MainActivity on top of the first: a second view model, a second
|
and a back press that lands on a stale copy of the app.
|
||||||
board, and a back press that lands on a stale copy of the same app.
|
|
||||||
-->
|
-->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -135,7 +130,7 @@
|
|||||||
|
|
||||||
Text, and images one at a time or several at once. A shared image
|
Text, and images one at a time or several at once. A shared image
|
||||||
becomes a note carrying it as an attachment, stored on the phone
|
becomes a note carrying it as an attachment, stored on the phone
|
||||||
and uploaded on the next sync that reaches a server (#5169).
|
and uploaded on the next sync that reaches a server.
|
||||||
|
|
||||||
image/* rather than */*: a photo or a screenshot is what people
|
image/* rather than */*: a photo or a screenshot is what people
|
||||||
share into a notes app. Claiming every type would put Inkwell in the
|
share into a notes app. Claiming every type would put Inkwell in the
|
||||||
@@ -180,7 +175,7 @@
|
|||||||
Where the system reports what happened to an install we committed. Not
|
Where the system reports what happened to an install we committed. Not
|
||||||
exported: the only sender is the PendingIntent this app handed to
|
exported: the only sender is the PendingIntent this app handed to
|
||||||
PackageInstaller. Without it a failed install would be indistinguishable
|
PackageInstaller. Without it a failed install would be indistinguishable
|
||||||
from someone declining the dialog (Scribe #2438).
|
from someone declining the dialog.
|
||||||
-->
|
-->
|
||||||
<!--
|
<!--
|
||||||
Hands an attachment to the app that opens its type. Not exported, as a
|
Hands an attachment to the app that opens its type. Not exported, as a
|
||||||
|
|||||||
@@ -18,24 +18,20 @@ import java.io.File
|
|||||||
*
|
*
|
||||||
* ## A PackageInstaller session, not an install intent
|
* ## A PackageInstaller session, not an install intent
|
||||||
*
|
*
|
||||||
* The obvious route — `ACTION_VIEW` on the APK with
|
* The obvious route, `ACTION_VIEW` on the APK with
|
||||||
* `application/vnd.android.package-archive` — is the one on-device install
|
* `application/vnd.android.package-archive`, is the one on-device install
|
||||||
* heuristics are tuned against, and it is what produces the "bypassing Android
|
* heuristics are tuned against, and it produces a "bypassing Android security"
|
||||||
* security" warning the operator saw on Minstrel (Scribe note 2437). It also never
|
* warning. It also never tells the OS that this app is the legitimate updater of its
|
||||||
* tells the OS that this app is the legitimate updater of its own package, and it
|
* own package, and it returns nothing: a failed install is indistinguishable from a
|
||||||
* returns nothing: a failed install is indistinguishable from a person dismissing
|
* person dismissing the dialog.
|
||||||
* the dialog.
|
|
||||||
*
|
*
|
||||||
* A session says who is doing what. On Android 12+ it can also declare that no user
|
* A session says who is doing what. On Android 12+ it can also declare that no user
|
||||||
* action is required, which — paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION` —
|
* action is required, which, paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION`,
|
||||||
* removes the confirmation dialog entirely on the UPDATE path. Not on a first
|
* removes the confirmation dialog on the UPDATE path. Not on a first install: the
|
||||||
* install: the OS will not let an app quietly put a NEW package on a device, which
|
* OS will not let an app quietly put a NEW package on a device.
|
||||||
* is right.
|
|
||||||
*
|
*
|
||||||
* Two things from that same research that are NOT done here, deliberately:
|
* Neither `setRequestUpdateOwnership` nor `REQUEST_INSTALL_PACKAGES` is what avoids
|
||||||
* `setRequestUpdateOwnership` was chased and turned out to be a red herring, and
|
* the warning; the session is.
|
||||||
* `REQUEST_INSTALL_PACKAGES` is not the differentiator either — Mihon declares it
|
|
||||||
* too. The mechanism was the whole difference.
|
|
||||||
*
|
*
|
||||||
* ## The outcome comes back
|
* ## The outcome comes back
|
||||||
*
|
*
|
||||||
@@ -57,8 +53,7 @@ object AppUpdate {
|
|||||||
* A debuggable build is signed with a debug key (CI's unsigned path, or a local
|
* A debuggable build is signed with a debug key (CI's unsigned path, or a local
|
||||||
* build), and Android refuses the release-signed APK over it with
|
* build), and Android refuses the release-signed APK over it with
|
||||||
* INSTALL_FAILED_UPDATE_INCOMPATIBLE. Offering that update would be a download
|
* INSTALL_FAILED_UPDATE_INCOMPATIBLE. Offering that update would be a download
|
||||||
* that can only fail, so a debug build says so instead (family idea #5103,
|
* that can only fail, so a debug build says so instead.
|
||||||
* practice 9).
|
|
||||||
*/
|
*/
|
||||||
fun selfUpdates(context: Context): Boolean =
|
fun selfUpdates(context: Context): Boolean =
|
||||||
(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0
|
(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0
|
||||||
@@ -125,10 +120,9 @@ object AppUpdate {
|
|||||||
val params =
|
val params =
|
||||||
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
// Only honoured on an UPDATE of an app signed with the same key —
|
// Only honoured on an UPDATE of an app signed with the same key, which
|
||||||
// exactly our case, and the reason the signing work had to land
|
// is this case. Android ignores it for anything else rather than
|
||||||
// first. Android ignores it for anything else rather than failing,
|
// failing, so there is no need to guard on which it is.
|
||||||
// so there is no need to guard on which it is.
|
|
||||||
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,11 +144,8 @@ object AppUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream the APK into the session.
|
* Stream the APK into the session. Its own function so a stream inside a session
|
||||||
*
|
* inside a try isn't three nested things to hold at once.
|
||||||
* Its own function only because the two nested `use` blocks read badly inline —
|
|
||||||
* and detekt agreed, which is fair: a stream inside a session inside a try is
|
|
||||||
* three things to hold at once.
|
|
||||||
*/
|
*/
|
||||||
private fun writeApk(
|
private fun writeApk(
|
||||||
session: PackageInstaller.Session,
|
session: PackageInstaller.Session,
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ import android.content.Context
|
|||||||
*
|
*
|
||||||
* Returns null rather than a fallback string, because the two callers want
|
* Returns null rather than a fallback string, because the two callers want
|
||||||
* different ones: the UI wants a localized "unknown" from string resources, the
|
* 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 —
|
* client header wants the literal the core recognizes. With no version tags, the
|
||||||
* with no version tags, the artifact's self-report is the only answer to "which
|
* artifact's self-report is the only answer to "which build is this?", so a missing
|
||||||
* build is this?", so a missing name must read as missing and never as a plausible
|
* name must read as missing, never as a plausible default.
|
||||||
* default that nothing can contradict.
|
|
||||||
*/
|
*/
|
||||||
fun Context.installedVersionName(): String? =
|
fun Context.installedVersionName(): String? =
|
||||||
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
|
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
|
||||||
|
|||||||
@@ -32,11 +32,9 @@ class InkwellApplication : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
// Introduce this app to any server it links to, before anything can sync.
|
// Introduce this app to any server it links to, before anything can sync. The
|
||||||
// The core cannot name us — the same crate is compiled into the desktop app,
|
// core can't name its host app, since the same crate is compiled into the
|
||||||
// and it used to announce every phone as `inkwell-desktop` carrying the
|
// desktop app. "unknown" rather than a guess when the package manager won't say.
|
||||||
// core crate's own version. "unknown" rather than a guess when the package
|
|
||||||
// manager will not say (note 3127 §5).
|
|
||||||
setClientAgent("inkwell-android", installedVersionName() ?: "unknown")
|
setClientAgent("inkwell-android", installedVersionName() ?: "unknown")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import javax.crypto.SecretKey
|
|||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps the device token sealed under a key in the Android Keystore (family idea
|
* Keeps the device token sealed under a key in the Android Keystore. The core
|
||||||
* #5105, practice 12). The core stores what this returns, never the token itself.
|
* stores what this returns, never the token itself.
|
||||||
*
|
*
|
||||||
* The key never leaves this phone, so a copy of the app's files yields a token
|
* The key never leaves this phone, so a copy of the app's files yields a token
|
||||||
* nothing can open. That includes Android's backup, which stays on: an unlinked
|
* nothing can open. That includes Android's backup, which stays on: an unlinked
|
||||||
@@ -23,8 +23,6 @@ import javax.crypto.spec.GCMParameterSpec
|
|||||||
* Both methods answer null rather than throw, because an exception crossing into
|
* Both methods answer null rather than throw, because an exception crossing into
|
||||||
* the core would be a panic there. The core reads null from [sealToken] as "store
|
* the core would be a panic there. The core reads null from [sealToken] as "store
|
||||||
* it as is for now" and from [openToken] as "sign in again".
|
* it as is for now" and from [openToken] as "sign in again".
|
||||||
*
|
|
||||||
* Shaped after Minstrel's KeystoreSessionVault (Scribe snippet #5025).
|
|
||||||
*/
|
*/
|
||||||
class KeystoreTokenSeal : TokenSeal {
|
class KeystoreTokenSeal : TokenSeal {
|
||||||
override fun sealToken(token: String): String? =
|
override fun sealToken(token: String): String? =
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
|
|||||||
* both cover the display completely, so keeping the board's two-column grid
|
* both cover the display completely, so keeping the board's two-column grid
|
||||||
* measuring and recomposing underneath one would be pure waste.
|
* measuring and recomposing underneath one would be pure waste.
|
||||||
*
|
*
|
||||||
* Still no navigation library. Four destinations, each entered from exactly one
|
* 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
|
* 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)
|
* state that actually matters (which note is open, whether this device is linked)
|
||||||
* already lives in view models.
|
* already lives in view models.
|
||||||
@@ -257,9 +257,8 @@ private fun App(
|
|||||||
// the display, and nothing in the store cares. Saveable so a rotation does not
|
// the display, and nothing in the store cares. Saveable so a rotation does not
|
||||||
// close it.
|
// close it.
|
||||||
//
|
//
|
||||||
// The capture sheet used to keep its own flag here too. It is gone: the + button
|
// There is no separate capture sheet: the + button opens the editor on an unsaved
|
||||||
// opens the editor on an unsaved draft, so writing a note and editing one are the
|
// draft, so writing a note and editing one are the same surface.
|
||||||
// same surface with the same toolbar.
|
|
||||||
var showingSync by rememberSaveable { mutableStateOf(false) }
|
var showingSync by rememberSaveable { mutableStateOf(false) }
|
||||||
var showingTags by rememberSaveable { mutableStateOf(false) }
|
var showingTags by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -446,16 +445,12 @@ private fun ReminderAlarms(core: Inkwell) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Looking for an app update without being asked.
|
* Looking for an app update without being asked, so a new build is found without
|
||||||
*
|
* anyone going to look for one.
|
||||||
* 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,
|
* 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.
|
* 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
|
* Unlinked devices are skipped, since updates come from a linked server.
|
||||||
* there is nothing to ask.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AutomaticUpdate(
|
private fun AutomaticUpdate(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import com.fabledsword.inkwell.ui.epochMillis
|
|||||||
* ## AlarmManager, not WorkManager
|
* ## AlarmManager, not WorkManager
|
||||||
*
|
*
|
||||||
* The background sync runs on WorkManager and is right to: nobody minds whether it
|
* The background sync runs on WorkManager and is right to: nobody minds whether it
|
||||||
* happens at 3:05 or 3:19. A reminder minded very much. WorkManager's periodic
|
* happens at 3:05 or 3:19. A reminder does. WorkManager's periodic
|
||||||
* floor is fifteen minutes and it batches work into maintenance windows, so
|
* floor is fifteen minutes and it batches work into maintenance windows, so
|
||||||
* "remind me at 09:00" would routinely arrive at 09:14 — which is not a reminder,
|
* "remind me at 09:00" would routinely arrive at 09:14 — which is not a reminder,
|
||||||
* it is a rebuke.
|
* it is a rebuke.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package com.fabledsword.inkwell
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When each tag's lens was last opened on this phone, for the drawer's recent tags
|
* When each tag's lens was last opened on this phone, for the drawer's recent tags.
|
||||||
* (#5931). Opening a tag counts as using it, alongside the notes that carry it.
|
* Opening a tag counts as using it, alongside the notes that carry it.
|
||||||
*
|
*
|
||||||
* Kept on the device rather than synced: it is a convenience about where this
|
* Kept on the device rather than synced: it is a convenience about where this
|
||||||
* person's thumb goes on this screen, and losing it costs one tap.
|
* person's thumb goes on this screen, and losing it costs one tap.
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import android.util.Log
|
|||||||
* Without this the app would `commit` and learn nothing — a failed install would
|
* Without this the app would `commit` and learn nothing — a failed install would
|
||||||
* look exactly like a person deciding not to go ahead, and the update card would
|
* look exactly like a person deciding not to go ahead, and the update card would
|
||||||
* sit there claiming an update is available with no explanation of why nothing
|
* sit there claiming an update is available with no explanation of why nothing
|
||||||
* happened. That was the specific complaint recorded against Minstrel's first
|
* happened.
|
||||||
* attempt (Scribe #2438).
|
|
||||||
*
|
*
|
||||||
* The result is written to [UpdateOutcome] rather than notified: the app is on
|
* The result is written to [UpdateOutcome] rather than notified: the app is on
|
||||||
* screen when this fires — someone just tapped Update — so the place to say it is
|
* screen when this fires — someone just tapped Update — so the place to say it is
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.fabledsword.inkwell.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipDescription
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
@@ -10,6 +12,7 @@ import android.media.ExifInterface
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
@@ -40,15 +43,18 @@ sealed interface PickedFile {
|
|||||||
) : PickedFile
|
) : PickedFile
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What [AttachmentFiles.opener] made: an intent to start, or why there isn't one. */
|
/**
|
||||||
sealed interface Opener {
|
* What [AttachmentFiles] made to hand a file to another app — an intent to start, a
|
||||||
data class Ready(
|
* clip for the clipboard — or why there isn't one.
|
||||||
val intent: Intent,
|
*/
|
||||||
) : Opener
|
sealed interface Handout<out T> {
|
||||||
|
data class Ready<T>(
|
||||||
|
val value: T,
|
||||||
|
) : Handout<T>
|
||||||
|
|
||||||
data class Failed(
|
data class Failed(
|
||||||
val message: String,
|
val message: String,
|
||||||
) : Opener
|
) : Handout<Nothing>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,29 +123,56 @@ class AttachmentFiles(
|
|||||||
* An intent that hands the file to whatever app opens its type, or the reason
|
* An intent that hands the file to whatever app opens its type, or the reason
|
||||||
* there can't be one.
|
* there can't be one.
|
||||||
*
|
*
|
||||||
|
* Blocking; call it off the main thread, then start the intent from a screen.
|
||||||
|
*/
|
||||||
|
fun opener(attachment: Attachment): Handout<Intent> =
|
||||||
|
handOut(attachment, R.string.attach_open_failed) { uri ->
|
||||||
|
Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(uri, attachment.mime)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A clip that puts the file itself on the clipboard, so it pastes into another
|
||||||
|
* app as the picture rather than as a path.
|
||||||
|
*
|
||||||
|
* The clip carries the attachment's own type: built from the URI alone, its type
|
||||||
|
* would be guessed from the file name's extension, and a pasted image saved
|
||||||
|
* without one would paste as an unknown file. The clipboard grants whichever app
|
||||||
|
* pastes it read access to the one URI.
|
||||||
|
*
|
||||||
|
* Blocking; call it off the main thread.
|
||||||
|
*/
|
||||||
|
fun clip(attachment: Attachment): Handout<ClipData> =
|
||||||
|
handOut(attachment, R.string.attach_copy_failed) { uri ->
|
||||||
|
val label = attachment.filename.orEmpty()
|
||||||
|
ClipData(ClipDescription(label, arrayOf(attachment.mime)), ClipData.Item(uri))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file as a URI another app may read, made into whatever [make] builds from
|
||||||
|
* it.
|
||||||
|
*
|
||||||
* Copied out of the blob store first, under its real name: the store names files
|
* Copied out of the blob store first, under its real name: the store names files
|
||||||
* by hash with no extension, and a viewer shown `3f2a…` cannot tell a PDF from a
|
* by hash with no extension, and a viewer shown `3f2a…` cannot tell a PDF from a
|
||||||
* spreadsheet. The copy lives in the cache, which the system reclaims, and only
|
* spreadsheet. The copy lives in the cache, which the system reclaims, and only
|
||||||
* that directory is shared (`res/xml/file_paths.xml`).
|
* that directory is shared (`res/xml/file_paths.xml`).
|
||||||
*
|
|
||||||
* Blocking; call it off the main thread, then start the intent from a screen.
|
|
||||||
*/
|
*/
|
||||||
fun opener(attachment: Attachment): Opener {
|
private fun <T> handOut(
|
||||||
|
attachment: Attachment,
|
||||||
|
@StringRes failed: Int,
|
||||||
|
make: (Uri) -> T,
|
||||||
|
): Handout<T> {
|
||||||
val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File)
|
val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File)
|
||||||
if (source == null) return Opener.Failed(app.getString(R.string.attach_not_here))
|
if (source == null) return Handout.Failed(app.getString(R.string.attach_not_here))
|
||||||
val dir = File(app.cacheDir, "$OPEN_DIR/${attachment.id}")
|
val dir = File(app.cacheDir, "$OPEN_DIR/${attachment.id}")
|
||||||
val copy = File(dir, safeName(attachment.filename))
|
val copy = File(dir, safeName(attachment.filename))
|
||||||
return try {
|
return try {
|
||||||
dir.mkdirs()
|
dir.mkdirs()
|
||||||
if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true)
|
if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true)
|
||||||
val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", copy)
|
Handout.Ready(make(FileProvider.getUriForFile(app, "${app.packageName}.files", copy)))
|
||||||
val view =
|
|
||||||
Intent(Intent.ACTION_VIEW)
|
|
||||||
.setDataAndType(uri, attachment.mime)
|
|
||||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
Opener.Ready(view)
|
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Opener.Failed(app.getString(R.string.attach_open_failed, e.message ?: attachment.filename.orEmpty()))
|
Handout.Failed(app.getString(failed, e.message ?: attachment.filename.orEmpty()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a type is drawn as a picture rather than offered as a file. SVG is
|
* Whether a type is drawn as a picture rather than offered as a file. SVG is
|
||||||
* excluded on every surface: it is a document that can carry script (#1981).
|
* excluded on every surface: it is a document that can carry script.
|
||||||
* The same rule as `rendersInline` in the web's `notes/attachments.ts`.
|
* The same rule as `rendersInline` in the web's `notes/attachments.ts`.
|
||||||
*/
|
*/
|
||||||
fun rendersInline(mime: String): Boolean {
|
fun rendersInline(mime: String): Boolean {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package com.fabledsword.inkwell.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -33,8 +37,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -115,6 +121,7 @@ fun EditorAttachments(
|
|||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
val open = rememberOpener()
|
val open = rememberOpener()
|
||||||
|
val copy = rememberCopier()
|
||||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||||
attachments.forEach { attachment ->
|
attachments.forEach { attachment ->
|
||||||
val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) }
|
val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) }
|
||||||
@@ -124,6 +131,7 @@ fun EditorAttachments(
|
|||||||
attachment = attachment,
|
attachment = attachment,
|
||||||
maxPx = EDITOR_IMAGE_PX,
|
maxPx = EDITOR_IMAGE_PX,
|
||||||
onClick = { open(attachment) },
|
onClick = { open(attachment) },
|
||||||
|
onLongClick = { copy(attachment) },
|
||||||
) {
|
) {
|
||||||
FileRow(attachment = attachment, onClick = { open(attachment) })
|
FileRow(attachment = attachment, onClick = { open(attachment) })
|
||||||
}
|
}
|
||||||
@@ -205,6 +213,9 @@ private sealed interface ImageLoad {
|
|||||||
/**
|
/**
|
||||||
* An attachment drawn as a picture, decoded off the main thread.
|
* An attachment drawn as a picture, decoded off the main thread.
|
||||||
*
|
*
|
||||||
|
* In the editor a tap opens it full size in the phone's viewer and a long press
|
||||||
|
* copies it, the same gesture that copies an image in a browser.
|
||||||
|
*
|
||||||
* [fallback] is what draws when it can't be: a file this device hasn't downloaded
|
* [fallback] is what draws when it can't be: a file this device hasn't downloaded
|
||||||
* yet, or bytes that don't decode. It is a file row, so the note still shows that
|
* yet, or bytes that don't decode. It is a file row, so the note still shows that
|
||||||
* something is attached instead of a gap.
|
* something is attached instead of a gap.
|
||||||
@@ -214,6 +225,7 @@ private fun AttachmentImage(
|
|||||||
attachment: Attachment,
|
attachment: Attachment,
|
||||||
maxPx: Int,
|
maxPx: Int,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
|
onLongClick: (() -> Unit)? = null,
|
||||||
fallback: @Composable () -> Unit,
|
fallback: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val files = LocalAttachmentFiles.current
|
val files = LocalAttachmentFiles.current
|
||||||
@@ -234,7 +246,15 @@ private fun AttachmentImage(
|
|||||||
)
|
)
|
||||||
is ImageLoad.Ready -> {
|
is ImageLoad.Ready -> {
|
||||||
val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT)
|
val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT)
|
||||||
val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
|
val tap =
|
||||||
|
onClick?.let { click ->
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClickLabel = stringResource(R.string.attach_view),
|
||||||
|
onLongClickLabel = onLongClick?.let { stringResource(R.string.attach_copy) },
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
onClick = click,
|
||||||
|
)
|
||||||
|
} ?: Modifier
|
||||||
Image(
|
Image(
|
||||||
bitmap = state.bitmap,
|
bitmap = state.bitmap,
|
||||||
contentDescription = attachment.filename,
|
contentDescription = attachment.filename,
|
||||||
@@ -311,8 +331,39 @@ private fun rememberOpener(): (Attachment) -> Unit {
|
|||||||
if (files != null) {
|
if (files != null) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) {
|
when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) {
|
||||||
is Opener.Ready -> start(context, opener)
|
is Handout.Ready -> start(context, opener.value)
|
||||||
is Opener.Failed -> toast(context, opener.message)
|
is Handout.Failed -> toast(context, opener.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put an image on the clipboard, or say why not.
|
||||||
|
*
|
||||||
|
* Android 13 and later show their own confirmation when anything is copied, so the
|
||||||
|
* toast is only for the versions that don't — two notices for one copy reads as two
|
||||||
|
* copies.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberCopier(): (Attachment) -> Unit {
|
||||||
|
val files = LocalAttachmentFiles.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
return { attachment ->
|
||||||
|
if (files != null) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
scope.launch {
|
||||||
|
when (val clip = withContext(Dispatchers.IO) { files.clip(attachment) }) {
|
||||||
|
is Handout.Ready -> {
|
||||||
|
context.getSystemService(ClipboardManager::class.java).setPrimaryClip(clip.value)
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
toast(context, context.getString(R.string.attach_copied))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Handout.Failed -> toast(context, clip.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,10 +372,10 @@ private fun rememberOpener(): (Attachment) -> Unit {
|
|||||||
|
|
||||||
private fun start(
|
private fun start(
|
||||||
context: Context,
|
context: Context,
|
||||||
opener: Opener.Ready,
|
intent: Intent,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
context.startActivity(opener.intent)
|
context.startActivity(intent)
|
||||||
} catch (expected: ActivityNotFoundException) {
|
} catch (expected: ActivityNotFoundException) {
|
||||||
toast(context, context.getString(R.string.attach_no_app))
|
toast(context, context.getString(R.string.attach_no_app))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
@@ -28,6 +31,7 @@ import androidx.compose.ui.focus.focusRequester
|
|||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
@@ -39,7 +43,7 @@ import com.fabledsword.inkwell.R
|
|||||||
* The note's body, as fields and checkboxes rather than as markup.
|
* 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
|
* 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.
|
* than `- [ ] ` to read and edit around. The note's stored markup is the same.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BlockBody(
|
fun BlockBody(
|
||||||
@@ -89,8 +93,8 @@ fun BlockBody(
|
|||||||
val remaining = blocks.withoutIndex(index)
|
val remaining = blocks.withoutIndex(index)
|
||||||
onChange(remaining)
|
onChange(remaining)
|
||||||
// The row above — or, for the FIRST row, whichever one takes
|
// The row above — or, for the FIRST row, whichever one takes
|
||||||
// its place. `index - 1` alone is -1 there, which left the
|
// its place. `index - 1` alone is -1 there, which would leave
|
||||||
// keyboard up with nothing focused.
|
// the keyboard up with nothing focused.
|
||||||
onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id)
|
onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -114,7 +118,7 @@ fun BlockBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A run of prose: one ordinary multi-line field, exactly as the editor always had.
|
* A run of prose: one ordinary multi-line field.
|
||||||
*
|
*
|
||||||
* Leaving it is when a `- [ ] ` typed by hand becomes a real checklist item — see
|
* 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.
|
* [promotingTasks] for why blur is the only safe moment to do that.
|
||||||
@@ -161,8 +165,7 @@ private fun TaskBlock(
|
|||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Material sizes every interactive component to a 48dp touch target, and on a
|
// 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
|
// checklist that IS the row height.
|
||||||
// even after the field's own padding came off.
|
|
||||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
@@ -203,25 +206,23 @@ private fun TaskBlock(
|
|||||||
* Material's floor is 48dp and this is deliberately under it. That floor is sized for
|
* 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
|
* 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
|
* 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
|
* neighbouring item — visible, and undone by tapping again. Twelve of those dp buy a
|
||||||
* dp for a list that fits on a screen is what was asked for, twice.
|
* list that fits on a screen.
|
||||||
*/
|
*/
|
||||||
private val ROW_TOUCH = 36.dp
|
private val ROW_TOUCH = 36.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The field a block is typed into.
|
* The field a block is typed into.
|
||||||
*
|
*
|
||||||
* `BasicTextField`, not the Material one [PlainTextField] wraps, and the reason is
|
* `BasicTextField`, not the Material one [PlainTextField] wraps, for density.
|
||||||
* density. Material's TextField puts 16dp above and below its text — padding that
|
* Material's TextField puts 16dp above and below its text: padding that makes a FORM
|
||||||
* makes a FORM field comfortable to hit, and that on a checklist IS the row height. It
|
* field comfortable to hit, and that on a checklist IS the row height.
|
||||||
* 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
|
* `BasicTextField` has no container or indicator to strip. What it does not supply
|
||||||
* container and an indicator; `BasicTextField` never had either, so there is no box
|
* and this must:
|
||||||
* 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
|
* - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK and is
|
||||||
* default that made the editor's toolbar invisible in dark mode. Set, not inherited.
|
* invisible in dark mode. Set, not inherited.
|
||||||
* - the cursor brush, which would otherwise be black for the same reason.
|
* - 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.
|
* - the placeholder, which is a plain Text behind the field rather than a slot.
|
||||||
*
|
*
|
||||||
@@ -241,6 +242,15 @@ private fun BlockField(
|
|||||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
) {
|
) {
|
||||||
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface)
|
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
// Kept for the arrow keys, which ask which line the caret is on. Read only when
|
||||||
|
// one is pressed, never while composing.
|
||||||
|
var layout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||||
|
val line = { offset: Int -> layout?.getLineForOffset(offset) ?: 0 }
|
||||||
|
val arrows =
|
||||||
|
Modifier.arrowKeysMoveCaret(
|
||||||
|
atTop = { line(value.selection.end) == 0 },
|
||||||
|
atBottom = { line(value.selection.end) == (layout?.lineCount ?: 1) - 1 },
|
||||||
|
)
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
if (hint != null && value.text.isEmpty()) {
|
if (hint != null && value.text.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -252,12 +262,13 @@ private fun BlockField(
|
|||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().then(arrows),
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
singleLine = singleLine,
|
singleLine = singleLine,
|
||||||
textStyle = style,
|
textStyle = style,
|
||||||
keyboardOptions = keyboardOptions,
|
keyboardOptions = keyboardOptions,
|
||||||
keyboardActions = keyboardActions,
|
keyboardActions = keyboardActions,
|
||||||
|
onTextLayout = { layout = it },
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import androidx.compose.ui.input.pointer.pointerInput
|
|||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
import com.fabledsword.inkwell.core.Note
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
// Reordering the board by dragging a card, as on the web (#5176): hold a card, then
|
// Reordering the board by dragging a card, as on the web: hold a card, then move it.
|
||||||
// move it. The hold is the same long press that opens the card's menu, so the menu
|
// The hold is the same long press that opens the card's menu, so the menu appears
|
||||||
// appears first and closes as the card starts to move. Lifting without moving
|
// first and closes as the card starts to move. Lifting without moving leaves the
|
||||||
// leaves the menu open, as before. Moving BEFORE the hold completes is a scroll.
|
// menu open. Moving BEFORE the hold completes is a scroll.
|
||||||
//
|
//
|
||||||
// Watched on the Initial pass, ahead of the grid's scrolling and the card's own
|
// Watched on the Initial pass, ahead of the grid's scrolling and the card's own
|
||||||
// click handling. Nothing is consumed until the hold has completed and the finger
|
// click handling. Nothing is consumed until the hold has completed and the finger
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import com.fabledsword.inkwell.core.Label
|
|||||||
import com.fabledsword.inkwell.core.NoteFacets
|
import com.fabledsword.inkwell.core.NoteFacets
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The board's filters: the same three the web's Filters panel offers (FilterBar.vue,
|
* The board's filters: the same three the web's Filters panel offers (FilterBar.vue).
|
||||||
* trimmed to these in #5180). Tags a note must ALL carry, has an attachment, and
|
* Tags a note must ALL carry, has an attachment, and shared with me. Reminders and
|
||||||
* shared with me. Reminders and dates have their own lenses, here as on the web.
|
* dates have their own lenses, here as on the web.
|
||||||
*
|
*
|
||||||
* Only the main board filters, as on the web. Opening Archive, Trash or a tag from
|
* Only the main board filters, as on the web. Opening Archive, Trash or a tag from
|
||||||
* the drawer starts that view unfiltered. A search narrows whichever view is open,
|
* the drawer starts that view unfiltered. A search narrows whichever view is open,
|
||||||
|
|||||||
@@ -356,10 +356,9 @@ private fun SearchBar(
|
|||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
// No `statusBarsPadding()` here. The Scaffold this sits in already applies
|
// No `statusBarsPadding()` here. The Scaffold this sits in already applies
|
||||||
// the system-bar insets to its content padding, so adding them again put
|
// the system-bar insets to its content padding, so adding them again would
|
||||||
// the whole status bar's height of empty space above the search field —
|
// put a status bar's height of empty space above the search field. Insets
|
||||||
// roughly a centimetre of nothing at the top of the first screen anyone
|
// get consumed once, by whichever component owns the edge.
|
||||||
// sees. Insets get consumed once, by whichever component owns the edge.
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -410,7 +409,7 @@ private fun NavigationDrawer(
|
|||||||
onOpenSync: () -> Unit,
|
onOpenSync: () -> Unit,
|
||||||
onManageTags: () -> Unit,
|
onManageTags: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Every tag, once asked for; until then the few in use (#5931).
|
// Every tag, once asked for; until then the few in use.
|
||||||
var allTags by rememberSaveable { mutableStateOf(false) }
|
var allTags by rememberSaveable { mutableStateOf(false) }
|
||||||
val shown = if (allTags) labels else recentTags(labels, { recency[it.id] ?: 0L }, { it.name })
|
val shown = if (allTags) labels else recentTags(labels, { recency[it.id] ?: 0L }, { it.name })
|
||||||
|
|
||||||
@@ -444,7 +443,7 @@ private fun NavigationDrawer(
|
|||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tags last, as on the web (#5915): it is the one list here that grows
|
// Tags last, as on the web: it is the one list here that grows
|
||||||
// without limit, so everything with a fixed place sits above it.
|
// without limit, so everything with a fixed place sits above it.
|
||||||
//
|
//
|
||||||
// The header renders even with no tags, unlike the rows below it: the
|
// The header renders even with no tags, unlike the rows below it: the
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ data class BoardState(
|
|||||||
/**
|
/**
|
||||||
* A search narrows the board in view rather than replacing it, as on the web,
|
* A search narrows the board in view rather than replacing it, as on the web,
|
||||||
* where the box writes `?q=` beside the Filters panel's facets and one query
|
* where the box writes `?q=` beside the Filters panel's facets and one query
|
||||||
* answers both (Scribe #2942). "These words, in notes tagged grocery" is the
|
* answers both. "These words, in notes tagged grocery" is the question tags exist
|
||||||
* question tags exist to help ask (note 2930).
|
* to help ask.
|
||||||
*/
|
*/
|
||||||
val searching: Boolean get() = query.isNotBlank()
|
val searching: Boolean get() = query.isNotBlank()
|
||||||
|
|
||||||
@@ -297,10 +297,8 @@ class BoardViewModel(
|
|||||||
/**
|
/**
|
||||||
* Open the editor on a note that does not exist yet.
|
* 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
|
* The + button opens this rather than a separate capture sheet, so a note being
|
||||||
* WRITTEN could not be given a colour, a reminder or a checklist — those live on
|
* WRITTEN can be given a colour, a reminder or a checklist from the same toolbar.
|
||||||
* 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
|
* 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
|
* editor renders it without knowing that "not saved yet" is a state it can be
|
||||||
@@ -462,7 +460,7 @@ class BoardViewModel(
|
|||||||
EditorAction.DismissError -> dismissError()
|
EditorAction.DismissError -> dismissError()
|
||||||
|
|
||||||
// Sent on an idle debounce while typing, and again on close. Writing
|
// Sent on an idle debounce while typing, and again on close. Writing
|
||||||
// this often is affordable because a body write no longer snapshots a
|
// this often is affordable because a body write doesn't snapshot a
|
||||||
// revision — the core keeps one per editing session, not one per save.
|
// revision — the core keeps one per editing session, not one per save.
|
||||||
is EditorAction.SaveText ->
|
is EditorAction.SaveText ->
|
||||||
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
||||||
@@ -567,12 +565,9 @@ class BoardViewModel(
|
|||||||
* lie than showing it one frame stale.
|
* lie than showing it one frame stale.
|
||||||
*
|
*
|
||||||
* While a search is running the QUERY is re-run rather than the board's
|
* 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
|
* destination: running `load` here would replace the hits with the whole board,
|
||||||
* board, which is why this branch exists at all. It used to keep the existing
|
* and keeping the old list would leave a trashed hit on screen beside a snackbar
|
||||||
* list instead, and that was right for a note whose place in the pile changed
|
* saying it was gone.
|
||||||
* 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(
|
private fun mutate(
|
||||||
closeEditor: Boolean = false,
|
closeEditor: Boolean = false,
|
||||||
@@ -596,9 +591,8 @@ class BoardViewModel(
|
|||||||
notes = notes,
|
notes = notes,
|
||||||
// Only REFRESHES an open editor; it must never open one.
|
// Only REFRESHES an open editor; it must never open one.
|
||||||
// `editing != null` IS "the editor is on screen", so writing
|
// `editing != null` IS "the editor is on screen", so writing
|
||||||
// the reloaded note in unconditionally meant any mutation
|
// the reloaded note in unconditionally would throw the editor
|
||||||
// started from the BOARD threw the editor open on top of it —
|
// open over the board whenever a card's checkbox is ticked.
|
||||||
// which is exactly what ticking a checkbox on a card did.
|
|
||||||
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
|
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
|
||||||
saving = false,
|
saving = false,
|
||||||
error = notice(),
|
error = notice(),
|
||||||
@@ -619,7 +613,7 @@ class BoardViewModel(
|
|||||||
* rather than the pure text helpers the editor uses: nothing here is holding a
|
* 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.
|
* 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).
|
* `index` is the item's ordinal, which is its id.
|
||||||
*/
|
*/
|
||||||
fun toggleItem(
|
fun toggleItem(
|
||||||
note: Note,
|
note: Note,
|
||||||
|
|||||||
@@ -6,11 +6,7 @@ package com.fabledsword.inkwell.ui
|
|||||||
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
|
// 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.
|
// 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
|
// A note's fill is one neutral; only its tags carry hue.
|
||||||
// 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
|
// 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
|
// over the same key order, and the two must agree exactly or a tag is one colour on
|
||||||
@@ -20,9 +16,8 @@ package com.fabledsword.inkwell.ui
|
|||||||
//
|
//
|
||||||
// NO COMPOSE IN THIS FILE, deliberately. It is the half of the pair that CAN be
|
// 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
|
// 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
|
// `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. See
|
||||||
// web side has no test runner at all, so this test is the only mechanical guard the
|
// the fixture comment in colors.ts.
|
||||||
// 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 colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
|
||||||
@@ -79,12 +74,10 @@ fun derivedTint(id: String): String {
|
|||||||
* `default`: the server mints one as `Label(owner_id=…, name=name)` with no colour,
|
* `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.
|
* 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
|
* Derived rather than stored when a label is created: most tags are born from typing
|
||||||
* a hashed colour written at each of the four places a label can be born — and named
|
* `#grocery` through `find_or_create_label`, not from a management screen, and
|
||||||
* the risk itself: `find_or_create_label` is "easy to miss, and it is the common one",
|
* deriving has no creation point to miss and nothing to backfill. The cost is that
|
||||||
* since most tags are born from typing `#grocery`, not from a management screen.
|
* renaming a tag recolours it, which is fair: the name IS the tag.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Folded because `#Todo` renamed to `#todo` is the same tag and should not change
|
* Folded because `#Todo` renamed to `#todo` is the same tag and should not change
|
||||||
* colour. [foldTag] and the web's are both locale-independent lowercasing, so the
|
* colour. [foldTag] and the web's are both locale-independent lowercasing, so the
|
||||||
@@ -104,6 +97,6 @@ fun resolvedLabelColor(
|
|||||||
/**
|
/**
|
||||||
* How two tag names compare: equal once every letter is lowercased, accented ones
|
* How two tag names compare: equal once every letter is lowercased, accented ones
|
||||||
* included, so `#Café` and `#CAFÉ` are one tag. The core's `derive::fold`, the server's
|
* included, so `#Café` and `#CAFÉ` are one tag. The core's `derive::fold`, the server's
|
||||||
* `labeling.named` and the web's `foldTag` fold the same way (#5385).
|
* `labeling.named` and the web's `foldTag` fold the same way.
|
||||||
*/
|
*/
|
||||||
fun foldTag(name: String): String = name.lowercase()
|
fun foldTag(name: String): String = name.lowercase()
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import android.net.Uri
|
|||||||
/**
|
/**
|
||||||
* Everything the editor can ask for, as one type.
|
* Everything the editor can ask for, as one type.
|
||||||
*
|
*
|
||||||
* The alternative was a bundle of twenty callbacks, and it was a bad one: twenty
|
* One `(EditorAction) -> Unit` rather than twenty same-shaped `(String, String) ->
|
||||||
* same-shaped `(String, String) -> Unit` parameters is a place for two of them to
|
* Unit` callbacks, where two could get swapped with nothing to catch it. It costs a
|
||||||
* get swapped, with nothing to catch it. One `(EditorAction) -> Unit` costs a
|
* `when` at the far end and buys EXHAUSTIVENESS: adding a variant here breaks the
|
||||||
* `when` at the far end and gets EXHAUSTIVENESS in exchange — adding a variant
|
* dispatcher until it is handled.
|
||||||
* here breaks the dispatcher until it is handled, which is precisely the guarantee
|
|
||||||
* the callback bundle could not offer.
|
|
||||||
*
|
*
|
||||||
* No variant carries a note id. The editor is open on exactly one note and the
|
* No variant carries a note id. The editor is open on exactly one note and the
|
||||||
* dispatcher already has it, so threading it through every action would only
|
* dispatcher already has it, so threading it through every action would only
|
||||||
@@ -41,12 +39,10 @@ sealed interface EditorAction {
|
|||||||
|
|
||||||
data object DeleteForever : EditorAction
|
data object DeleteForever : EditorAction
|
||||||
|
|
||||||
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the
|
// No checklist actions. An item is a `- [ ] ` line of the body, so adding,
|
||||||
// body, so adding, renaming, ticking or deleting one is editing text — which the
|
// renaming, ticking or deleting one is editing text, through SaveText, with the
|
||||||
// editor already does, through SaveText, with the same autosave and the same
|
// same autosave and revision window as any other edit. Routing them through the
|
||||||
// revision window as any other edit. Routing them through the store would have
|
// store would hand back a note whose body disagrees with the field being typed in.
|
||||||
// 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.
|
* The note's MANUAL labels, replacing whatever was there.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import com.fabledsword.inkwell.core.checklistRender
|
|||||||
/**
|
/**
|
||||||
* One piece of a note body, as the editor DRAWS it.
|
* 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
|
* The note is still one markdown string underneath — this is a rendering and
|
||||||
* input shape, and nothing below the editor can tell it exists. [joinBlocks] puts the
|
* input shape, and nothing below the editor can tell it exists. [joinBlocks] puts the
|
||||||
* string back together on every edit.
|
* string back together on every edit.
|
||||||
*
|
*
|
||||||
@@ -36,8 +36,8 @@ data class EditorBlock(
|
|||||||
/**
|
/**
|
||||||
* Split a body into blocks, numbering them from [firstId].
|
* 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
|
* Which lines are items comes from the core, not from a pattern here: the grammar has
|
||||||
* written three times already and Kotlin is not going to be the fourth.
|
* three implementations already, and this file isn't a fourth.
|
||||||
*/
|
*/
|
||||||
fun splitBlocks(
|
fun splitBlocks(
|
||||||
body: String,
|
body: String,
|
||||||
@@ -95,13 +95,11 @@ val blocksSaver: Saver<List<EditorBlock>, String> =
|
|||||||
/**
|
/**
|
||||||
* What the return key does on a checklist item.
|
* What the return key does on a checklist item.
|
||||||
*
|
*
|
||||||
* `internal` rather than private because BlockBody.kt calls it. These three helpers
|
* `internal` rather than private because BlockBody.kt calls it: these helpers are the
|
||||||
* are the block MODEL and the composables are the block UI — one file was doing both,
|
* block MODEL, and the composables in BlockBody.kt are the block UI.
|
||||||
* 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
|
* 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
|
* prose, which is how a list ENDS: without it a list is impossible to get out of.
|
||||||
* 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
|
* 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.
|
* a rarity, and the caret is at the end for every ordinary use of this key.
|
||||||
|
|||||||
@@ -47,17 +47,11 @@ import com.fabledsword.inkwell.core.Note
|
|||||||
/**
|
/**
|
||||||
* The editor's action bar, along the top of the surface.
|
* 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
|
* Top rather than bottom because this surface is used for WRITING: the keyboard owns
|
||||||
* this strip of screen and did nothing that a back gesture does not already do, so
|
* the bottom of the display for most of a note's life, so a bar down there would ride
|
||||||
* the strip carries the actions instead.
|
* on the IME. That suits a send button, not a colour picker reached for between
|
||||||
*
|
* thoughts. The top is further from a thumb, and buys a bar that does not move while
|
||||||
* Top rather than bottom, now that this one surface is used for WRITING as well as
|
* you type.
|
||||||
* editing: the keyboard owns the bottom of the display for most of a note's life,
|
|
||||||
* so a bar down there spends its time riding on the IME. That is the right place
|
|
||||||
* for a send button and the wrong one for a colour picker, which is reached for
|
|
||||||
* between thoughts rather than at the end of them. The cost is honest — the top of
|
|
||||||
* a phone is further from a thumb than the bottom — and it buys a bar that does not
|
|
||||||
* move while you type.
|
|
||||||
*
|
*
|
||||||
* The three affordances with a permanent slot are the ones reached for while still
|
* The three affordances with a permanent slot are the ones reached for while still
|
||||||
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
|
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
|
||||||
@@ -84,17 +78,16 @@ fun EditorTopBar(
|
|||||||
onConfirmDelete: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
// Someone else's note (#5175): its text, at `edit`, and this account's own pin
|
// Someone else's note: its text, at `edit`, and this account's own pin
|
||||||
// and archive (#5176) are all that may change here, so the bar keeps the
|
// and archive are all that may change here, so the bar keeps the
|
||||||
// checklist button and an overflow of those two, and nothing that is the owner's.
|
// checklist button and an overflow of those two, and nothing that is the owner's.
|
||||||
val owner = access == NoteAccess.OWNER
|
val owner = access == NoteAccess.OWNER
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
// The only way out, and the only thing that needed a "save" button
|
// The only way out. Writes are continuous, so leaving IS saving, which is
|
||||||
// before writes became continuous. Leaving IS saving now, which is what
|
// what the line in the bottom corner says out loud.
|
||||||
// the line in the bottom corner is there to say out loud.
|
|
||||||
BackButton(onClose, R.string.editor_back)
|
BackButton(onClose, R.string.editor_back)
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
@@ -108,7 +101,7 @@ fun EditorTopBar(
|
|||||||
}
|
}
|
||||||
if (!readOnly) {
|
if (!readOnly) {
|
||||||
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
|
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
|
||||||
// a checklist is text now (M304), so there is no section to be
|
// a checklist is text, so there is no section to be
|
||||||
// already-showing and no reason a second list cannot start further
|
// already-showing and no reason a second list cannot start further
|
||||||
// down the same note.
|
// down the same note.
|
||||||
IconButton(onClick = onStartChecklist) {
|
IconButton(onClick = onStartChecklist) {
|
||||||
@@ -139,14 +132,11 @@ fun EditorTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
|
// EXPLICIT, and not optional. Material derives a bar's content colour from its
|
||||||
// Material derives a bar's content colour from its container via
|
// container via contentColorFor(), which maps a colour-SCHEME ROLE to its `on-`
|
||||||
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and
|
// pair and returns Unspecified for anything else. The card surface is a plain
|
||||||
// returns Unspecified for anything else. The card surface is a plain constant
|
// constant, not a role, so without this the icons draw with no colour filter:
|
||||||
// and not a role, so the icons drew with no colour filter: black vectors on a
|
// black vectors on a near-black bar, invisible in dark mode.
|
||||||
// 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
|
// 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
|
// to read against the card rather than the board, and the muted variant does
|
||||||
@@ -164,36 +154,24 @@ fun EditorTopBar(
|
|||||||
/**
|
/**
|
||||||
* The footer: when the note was last written, and the way out.
|
* 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
|
* **Where the note stands.** There is no save button: a note is saved continuously,
|
||||||
* note is saved continuously, so a button offering to do what already happened is a
|
* so a button offering to do what already happened would be a lie with a tap
|
||||||
* lie with a tap attached. But that left nothing on screen saying the work is safe,
|
* attached. The state says the work is safe instead, as a fact: Not saved yet →
|
||||||
* and "closing this keeps it" is not a thing anyone should have to be told twice. So
|
* Saving… → Edited just now is the whole lifecycle.
|
||||||
* 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
|
* **The way out.** Down here because of where hands are. The top-left corner is the
|
||||||
* top took the back arrow with it, which left the only exit from a full-screen
|
* furthest point on the display from a right-handed thumb, so the exit lives in the
|
||||||
* editor in the top-left corner — the furthest point on the display from a
|
* bottom corner, which with the keyboard up sits directly above it. The top-left
|
||||||
* right-handed thumb, and reached over the whole note to get to. The operator hit
|
* arrow stays as well: it is what habit, the system back gesture and TalkBack all
|
||||||
* that on the first device pass and was right to. So the exit lives in the bottom
|
* expect of a full-screen surface.
|
||||||
* corner, which with the keyboard up sits directly above it.
|
|
||||||
*
|
*
|
||||||
* The top-left arrow stays as well. Two affordances for one action is usually
|
* A checkmark in a filled tonal button, so it reads plainly as a control rather than
|
||||||
* clutter, but this is the case that earns it: the arrow is what habit, the system
|
* as a checklist tick. Its content description is "Done", which is what it says read
|
||||||
* back gesture and TalkBack all expect of a full-screen surface, and removing it
|
* aloud.
|
||||||
* 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
|
* [DateUtils] rather than a hand-rolled formatter: it is localised, it already
|
||||||
* knows the difference between minutes, hours and yesterday, and getting plurals
|
* knows the difference between minutes, hours and yesterday, and it gets plurals
|
||||||
* right in every language is not this app's problem to solve twice.
|
* right in every language.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun EditorFooter(
|
fun EditorFooter(
|
||||||
@@ -206,7 +184,7 @@ fun EditorFooter(
|
|||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
// Rides above the keyboard, like the bar that used to be here. The
|
// Rides above the keyboard. The
|
||||||
// content Column deliberately does not also inset for the IME:
|
// content Column deliberately does not also inset for the IME:
|
||||||
// Scaffold measures this row at its lifted height and passes the
|
// Scaffold measures this row at its lifted height and passes the
|
||||||
// inset down.
|
// inset down.
|
||||||
@@ -224,16 +202,10 @@ fun EditorFooter(
|
|||||||
)
|
)
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onClose,
|
onClick = onClose,
|
||||||
// The BRAND, matching the board's compose FAB — the app's one existing
|
// The BRAND, matching the board's compose FAB: the app's one statement of
|
||||||
// statement of "this is the affirmative action here", now reused rather
|
// "this is the affirmative action here". Material's default
|
||||||
// than a second one invented.
|
// secondaryContainer is a baseline M3 colour this theme never sets, so the
|
||||||
//
|
// default would put an off-brand lilac in the corner of the editor.
|
||||||
// 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 =
|
colors =
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ import java.time.temporal.TemporalAdjusters
|
|||||||
|
|
||||||
// The two things you pick rather than type: a set of labels, and a time.
|
// The two things you pick rather than type: a set of labels, and a time.
|
||||||
//
|
//
|
||||||
// It was three. The colour sheet went with `note.color` in M315 — a card is one neutral
|
|
||||||
// surface now and colour lives on the tag, so the swatch grid was a control with nothing
|
|
||||||
// behind it.
|
|
||||||
//
|
|
||||||
// All bottom sheets rather than dialogs. A dialog takes the middle of the screen
|
// All bottom sheets rather than dialogs. A dialog takes the middle of the screen
|
||||||
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
|
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
|
||||||
// the note still visible above it — which matters when the choice you are making
|
// the note still visible above it — which matters when the choice you are making
|
||||||
|
|||||||
@@ -21,9 +21,8 @@ import com.fabledsword.inkwell.R
|
|||||||
|
|
||||||
// Shared by the board and the editor.
|
// Shared by the board and the editor.
|
||||||
//
|
//
|
||||||
// A failed save is most likely to happen WHILE the editor is open — that is where
|
// A failed save is most likely WHILE the editor is open, since that is where the
|
||||||
// the writes are — so a banner only the board could render meant the one screen
|
// writes are, so the editor needs the banner as much as the board does.
|
||||||
// that needed it was the one screen without it.
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ErrorBanner(
|
fun ErrorBanner(
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ private val TRAILING_PUNCTUATION = charArrayOf('.', ',', ';', ':', '!', '?')
|
|||||||
* The link a note that is nothing but a link files its preview under, or null.
|
* The link a note that is nothing but a link files its preview under, or null.
|
||||||
*
|
*
|
||||||
* Trimmed the way the server's `unfurl_queue.detect_urls` trims it, since that is the
|
* Trimmed the way the server's `unfurl_queue.detect_urls` trims it, since that is the
|
||||||
* key the preview arrives under (#5399). The web's `loneUrl` is the same rule, and
|
* key the preview arrives under. The web's `loneUrl` is the same rule, and
|
||||||
* both run the `urls` cases in `core/testdata/grammar.json` (here, by hand).
|
* both run the `urls` cases in `core/testdata/grammar.json` (here, by hand).
|
||||||
*/
|
*/
|
||||||
fun loneUrl(body: String): String? =
|
fun loneUrl(body: String): String? =
|
||||||
@@ -92,9 +92,8 @@ fun isLoneUrl(note: Note): Boolean = loneUrl(note.body) != null
|
|||||||
* The image sits in a strip down the left edge, cropped to the card's height, as in
|
* The image sits in a strip down the left edge, cropped to the card's height, as in
|
||||||
* the web's `LinkPreview.vue`. `image_url` is a REMOTE third-party address — the
|
* the web's `LinkPreview.vue`. `image_url` is a REMOTE third-party address — the
|
||||||
* linked page's og:image — so the phone fetches it from whatever host the link
|
* linked page's og:image — so the phone fetches it from whatever host the link
|
||||||
* points at, exactly as a browser does for the web card. The operator chose that
|
* points at, exactly as a browser does for the web card. Coil's disk cache means a
|
||||||
* parity knowingly (Scribe #3307). Coil's disk cache means a card scrolled past
|
* card scrolled past twice costs one download, not two.
|
||||||
* twice costs one download, not two.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LinkPreviewCard(
|
fun LinkPreviewCard(
|
||||||
@@ -106,7 +105,7 @@ fun LinkPreviewCard(
|
|||||||
// and the two paddings are named first. `standard:chain-method-continuation`
|
// and the two paddings are named first. `standard:chain-method-continuation`
|
||||||
// wants a `.` that follows a MULTILINE element glued to its closing paren —
|
// wants a `.` that follows a MULTILINE element glued to its closing paren —
|
||||||
// `).padding(…)` — which is unreadable, so the multiline element is avoided
|
// `).padding(…)` — which is unreadable, so the multiline element is avoided
|
||||||
// instead (Scribe #3110).
|
// instead.
|
||||||
val shape = RoundedCornerShape(PREVIEW_RADIUS)
|
val shape = RoundedCornerShape(PREVIEW_RADIUS)
|
||||||
val padH = if (compact) 8.dp else 10.dp
|
val padH = if (compact) 8.dp else 10.dp
|
||||||
val padV = if (compact) 6.dp else 8.dp
|
val padV = if (compact) 6.dp else 8.dp
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package com.fabledsword.inkwell.ui
|
|||||||
import com.fabledsword.inkwell.core.Note
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How this account holds a note (#5175), read from the core's `permission`.
|
* How this account holds a note, read from the core's `permission`.
|
||||||
*
|
*
|
||||||
* Someone else's note at [EDIT] may have its TEXT changed here; at [VIEW] it may
|
* Someone else's note at [EDIT] may have its TEXT changed here; at [VIEW] it may
|
||||||
* not. Either way its pin and archive are this account's own (#5176). The core
|
* not. Either way its pin and archive are this account's own. The core
|
||||||
* refuses the rest anyway — this only keeps the editor from offering what would be
|
* refuses the rest anyway — this only keeps the editor from offering what would be
|
||||||
* refused.
|
* refused.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ fun NoteCard(
|
|||||||
// NAMED rather than written inline in the chain below, and not for taste: ktlint's
|
// 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
|
// chain-method-continuation wants the next `.` glued to the closing paren of a
|
||||||
// multiline element — `).background(…)` — which is worse to read than a modifier
|
// 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
|
// with a name.
|
||||||
// in its chain, so this is the first place the rule bites.
|
|
||||||
val opening =
|
val opening =
|
||||||
Modifier.combinedClickable(
|
Modifier.combinedClickable(
|
||||||
onClickLabel = stringResource(R.string.board_open_note),
|
onClickLabel = stringResource(R.string.board_open_note),
|
||||||
@@ -147,13 +146,12 @@ private fun CardContents(
|
|||||||
// NoteBody, which shows the URL. Never a blank card.
|
// NoteBody, which shows the URL. Never a blank card.
|
||||||
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
|
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
|
||||||
|
|
||||||
// Body then checklist, in order — a note can carry both (M13 step 2). The
|
// Body then checklist, in order — a note can carry both. The first line of the
|
||||||
// first line of the body IS the note's name, at the same weight as the rest of
|
// body IS the note's name, at the same weight as the rest of it.
|
||||||
// it (M13 steps 3 and 4).
|
|
||||||
if (lonePreview != null) {
|
if (lonePreview != null) {
|
||||||
LinkPreviewCard(preview = lonePreview, compact = false)
|
LinkPreviewCard(preview = lonePreview, compact = false)
|
||||||
} else if (note.body.isNotBlank()) {
|
} else if (note.body.isNotBlank()) {
|
||||||
// A note shared with us to view has inert boxes (#5175): ticking one is
|
// A note shared with us to view has inert boxes: ticking one is
|
||||||
// changing its text, which is not ours to do.
|
// changing its text, which is not ours to do.
|
||||||
NoteBody(
|
NoteBody(
|
||||||
note = note,
|
note = note,
|
||||||
@@ -164,7 +162,7 @@ private fun CardContents(
|
|||||||
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
|
// 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
|
// 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
|
// 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.
|
// should be. The web does the same.
|
||||||
if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
|
if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
note.previews.forEach { preview ->
|
note.previews.forEach { preview ->
|
||||||
@@ -218,7 +216,7 @@ private fun NoteMenu(
|
|||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
onConfirmDelete: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Pin and archive are this account's own on someone else's note too (#5176);
|
// Pin and archive are this account's own on someone else's note too;
|
||||||
// trash is the owner's. A note shared with us never reaches our Trash, so the
|
// trash is the owner's. A note shared with us never reaches our Trash, so the
|
||||||
// trashed set is only ever the owner's.
|
// trashed set is only ever the owner's.
|
||||||
val owner = note.access == NoteAccess.OWNER
|
val owner = note.access == NoteAccess.OWNER
|
||||||
@@ -239,12 +237,12 @@ private fun NoteMenu(
|
|||||||
* The note's body, with its checklist drawn where it actually sits.
|
* 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
|
* 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
|
* of the body, and a card that showed the prose and then the list would put every
|
||||||
* every list in the wrong place — and, since the body already contains those lines,
|
* list in the wrong place — and, since the body already contains those lines, would
|
||||||
* would show each one twice.
|
* show each one twice.
|
||||||
*
|
*
|
||||||
* Which lines are items is asked of the core rather than matched here. The grammar is
|
* Which lines are items is asked of the core rather than matched here. The grammar has
|
||||||
* already written three times; a fourth in Compose would be a fourth place for a
|
* three implementations already; a fourth in Compose would be a fourth place for a
|
||||||
* checklist to change shape when it syncs.
|
* checklist to change shape when it syncs.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -333,12 +331,8 @@ private fun ChecklistRow(
|
|||||||
/**
|
/**
|
||||||
* One string of a note's own words, with every `#tag` in it drawn in that tag's colour.
|
* 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
|
* A tag still living in the prose is coloured in place rather than repeated as a chip:
|
||||||
* print such a tag twice — once where it was typed and once in the row above — and the
|
* the token you can see IS the text you would delete to remove the tag.
|
||||||
* 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
|
* 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,
|
* lines are checklist items. The grammar already exists three times (Rust, Python,
|
||||||
@@ -415,9 +409,8 @@ private fun LabelChips(labels: List<NoteLabel>) {
|
|||||||
* The reminder, red once it has passed.
|
* The reminder, red once it has passed.
|
||||||
*
|
*
|
||||||
* Red for overdue and neutral otherwise, matching the web card exactly — the same
|
* Red for overdue and neutral otherwise, matching the web card exactly — the same
|
||||||
* red-100/red-700 and black/5 pairs, resolved through the shared tint table. It
|
* red-100/red-700 and black/5 pairs, resolved through the shared tint table, so
|
||||||
* used to be blue for every reminder here, which made "you missed this" and
|
* "you missed this" and "coming up on Friday" don't look alike on a board of both.
|
||||||
* "coming up on Friday" look identical on a board full of both.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReminderChip(
|
private fun ReminderChip(
|
||||||
@@ -428,7 +421,7 @@ private fun ReminderChip(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whose note this is, when it is not only ours (#5175): "From Robin" on a note someone
|
* Whose note this is, when it is not only ours: "From Robin" on a note someone
|
||||||
* shared with us, "Shared" on one of ours that we shared. Nothing on a private note.
|
* shared with us, "Shared" on one of ours that we shared. Nothing on a private note.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -463,31 +456,21 @@ private val CARD_ELEVATION = 1.dp
|
|||||||
// too, so a note opened is the same object as the note on the board.
|
// too, so a note opened is the same object as the note on the board.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE CARD SURFACE — one neutral per theme (M315).
|
* THE CARD SURFACE — one neutral per theme.
|
||||||
*
|
*
|
||||||
* This used to be a function of the note: a palette fill for a tagged one, a colour
|
* A card's fill carries no meaning: nine keys are too few to identify anything on a
|
||||||
* generated from the id for the rest. Both are gone. The operator's verdict after four
|
* board of any size, and a coloured board teaches the eye to read hue as significant.
|
||||||
* passes — "my coloring attempt has failed and nothing looks right… we've tried a lot
|
* Colour lives on the TAG, beside the thing it names.
|
||||||
* 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
|
* The values are `neutral-900` on dark and white on light, the same as the web card
|
||||||
* `default` always was, and exactly what the web card and both editors already use, so
|
* and both editors. Mirrored as `NOTE_CARD_SURFACE` in `frontend/src/notes/colors.ts`
|
||||||
* 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`).
|
* (`bg-white dark:bg-neutral-900`).
|
||||||
*
|
*
|
||||||
* NOT a colour-scheme role: `surface` is the BOARD in this theme (neutral-50 / -950),
|
* 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
|
* 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
|
* baseline M3 greys rather than to the web's neutrals.
|
||||||
* a role that nearly does.
|
|
||||||
*
|
*
|
||||||
* Measured, against the operator's "not the same color as their background but close
|
* The card fill is deliberately the WEAKEST contrast on the card:
|
||||||
* to it" — the card fill is deliberately the WEAKEST number on the card:
|
|
||||||
*
|
*
|
||||||
* card vs board light #FFFFFF on #FAFAFA 1.04
|
* card vs board light #FFFFFF on #FAFAFA 1.04
|
||||||
* dark #171717 on #0A0A0A 1.10
|
* dark #171717 on #0A0A0A 1.10
|
||||||
@@ -498,10 +481,9 @@ private val CARD_ELEVATION = 1.dp
|
|||||||
* muted vs card light #404040 on #FFFFFF 10.37
|
* muted vs card light #404040 on #FFFFFF 10.37
|
||||||
* dark #E5E5E5 on #171717 14.23
|
* dark #E5E5E5 on #171717 14.23
|
||||||
*
|
*
|
||||||
* A card is not separated from the board by its fill and never was — the edge and the
|
* The edge and the shadow separate a card from the board, not its fill, which is why
|
||||||
* shadow do that, which is why 1.04 is enough and why it has to stay near 1. A fill
|
* 1.04 is enough and why it has to stay near 1: a fill that separated on its own would
|
||||||
* that separated on its own would be a panel, and a board of panels is the wall this
|
* make a board of panels.
|
||||||
* whole line of work started from.
|
|
||||||
*/
|
*/
|
||||||
fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT
|
fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT
|
||||||
|
|
||||||
|
|||||||
@@ -34,16 +34,14 @@ import kotlinx.coroutines.delay
|
|||||||
/**
|
/**
|
||||||
* The one writing surface: a new note and an existing one are the same screen.
|
* The one writing surface: a new note and an existing one are the same screen.
|
||||||
*
|
*
|
||||||
* Shaped like the capture sheet it replaced — a rounded card that begins below the
|
* Shaped like a sheet — a rounded card that begins below the status bar — so opening
|
||||||
* status bar — so opening a note still reads as something rising over the board
|
* a note reads as something rising over the board rather than a place you navigated
|
||||||
* rather than a place you navigated to. It is full height rather than a real
|
* to. It is full height rather than a real `ModalBottomSheet`: a sheet spends a
|
||||||
* `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session
|
* writing session negotiating with the IME for the bottom half of the display, and
|
||||||
* negotiating with the IME for the bottom half of the display, and the swipe-down it
|
* the swipe-down it buys is a gesture back already does.
|
||||||
* buys is a gesture back already does. The shape is what was worth keeping.
|
|
||||||
*
|
*
|
||||||
* The card's surface paints the WHOLE sheet rather than a panel inside it, so opening
|
* 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
|
* a note reads as the same object growing to fill the display.
|
||||||
* 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
|
* 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
|
* what already happened would be a lie with a tap attached; [EditorFooter] in the
|
||||||
@@ -69,7 +67,7 @@ fun NoteEditorScreen(
|
|||||||
// throwing away every character typed during the write.
|
// throwing away every character typed during the write.
|
||||||
//
|
//
|
||||||
// BLOCKS rather than one string, because a checklist item is drawn as a real
|
// 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
|
// checkbox 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.
|
// 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
|
// Saveable, because a new note has nothing to fall back on if the phone rotates
|
||||||
@@ -93,7 +91,7 @@ fun NoteEditorScreen(
|
|||||||
// Restore and Delete forever as the only things to do with it.
|
// Restore and Delete forever as the only things to do with it.
|
||||||
//
|
//
|
||||||
// A note shared with us to view is read-only for the same reason it is on the
|
// A note shared with us to view is read-only for the same reason it is on the
|
||||||
// web: nothing about it is ours to change (#5175). At edit, the text is, and the
|
// web: nothing about it is ours to change. At edit, the text is, and the
|
||||||
// rows that are the owner's (tags, reminder, files, previews) stay inert.
|
// rows that are the owner's (tags, reminder, files, previews) stay inert.
|
||||||
val access = note.access
|
val access = note.access
|
||||||
val readOnly = note.readOnlyHere
|
val readOnly = note.readOnlyHere
|
||||||
@@ -125,10 +123,8 @@ fun NoteEditorScreen(
|
|||||||
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every
|
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every
|
||||||
// keystroke, so the delay only ever elapses once typing stops.
|
// keystroke, so the delay only ever elapses once typing stops.
|
||||||
//
|
//
|
||||||
// Saving this often is affordable because a body write no longer costs a
|
// Saving this often is affordable because a body write doesn't cost a revision:
|
||||||
// revision: history snapshots once per editing session rather than once per
|
// history snapshots once per editing session rather than once per save.
|
||||||
// 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
|
// 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
|
// every toolbar button works moments after the first keystroke rather than
|
||||||
@@ -173,8 +169,7 @@ fun NoteEditorScreen(
|
|||||||
// container), which returns Unspecified for anything that is not a
|
// container), which returns Unspecified for anything that is not a
|
||||||
// colour-SCHEME ROLE. The card surface is not one, so the default publishes
|
// colour-SCHEME ROLE. The card surface is not one, so the default publishes
|
||||||
// Unspecified as LocalContentColor and everything inside that does not
|
// Unspecified as LocalContentColor and everything inside that does not
|
||||||
// set its own colour draws black — which is how the last toolbar became
|
// set its own colour draws black, invisible in dark mode.
|
||||||
// invisible in dark mode.
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -201,7 +196,7 @@ fun NoteEditorScreen(
|
|||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// Where the action bar used to be, carrying the two things that
|
// The two things that
|
||||||
// belong within reach of a thumb: whether the note is safe, and the
|
// 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
|
// way out. See [EditorFooter] for why the exit is down here and not
|
||||||
// only in the top-left corner.
|
// only in the top-left corner.
|
||||||
@@ -242,8 +237,7 @@ fun NoteEditorScreen(
|
|||||||
|
|
||||||
// A note is its body; its NAME is that body's first line, so there
|
// 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
|
// 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
|
// the line beneath it. Checklist items draw as boxes rather than as
|
||||||
// how the body is DRAWN — checklist items as boxes rather than as
|
|
||||||
// the markup for boxes.
|
// the markup for boxes.
|
||||||
BlockBody(
|
BlockBody(
|
||||||
blocks = blocks,
|
blocks = blocks,
|
||||||
@@ -253,9 +247,9 @@ fun NoteEditorScreen(
|
|||||||
onFocus = { focus = it },
|
onFocus = { focus = it },
|
||||||
)
|
)
|
||||||
|
|
||||||
// No checklist section. The items ARE lines of the field above
|
// No checklist section. The items ARE lines of the field above;
|
||||||
// (M304) — rendering them again down here is what would put every
|
// rendering them again down here would put every list on screen
|
||||||
// list on screen twice.
|
// twice.
|
||||||
|
|
||||||
if (note.labels.isNotEmpty()) {
|
if (note.labels.isNotEmpty()) {
|
||||||
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
|
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
|
||||||
@@ -350,6 +344,6 @@ private const val ANY_TYPE = "*/*"
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The card's top corner radius — Material's extra-large, which is what a bottom
|
* 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.
|
* sheet uses.
|
||||||
*/
|
*/
|
||||||
private val SHEET_CORNER = 28.dp
|
private val SHEET_CORNER = 28.dp
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import com.fabledsword.inkwell.R
|
|||||||
* a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
|
* a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
|
||||||
* background the same way in both.
|
* background the same way in both.
|
||||||
*
|
*
|
||||||
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one
|
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE has none — the
|
||||||
* at all (M315) — the card is one neutral per theme, held in NoteCard.kt beside 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,
|
* 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
|
* the inline `#tag`, and the panels and banners that borrow a hue to say what they
|
||||||
* are.
|
* are.
|
||||||
@@ -55,8 +55,8 @@ data class NoteTint(
|
|||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* The pale fill of a PANEL, a banner, an update card or a picker swatch — the
|
* 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
|
* places that borrow a hue to say what they are. Not a note's: a card has one
|
||||||
* has one neutral surface and does not come through this table at all.
|
* neutral surface and does not come through this table at all.
|
||||||
*/
|
*/
|
||||||
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
|
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
|
||||||
|
|
||||||
@@ -69,19 +69,16 @@ data class NoteTint(
|
|||||||
*
|
*
|
||||||
* Kept apart from [tagInk] because the reminder pill is not a tag: it borrows the
|
* 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`
|
* 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
|
* text is transcribed from NoteCard.vue's literal classes, so it follows those,
|
||||||
* one value; making the tag ink one step deeper (M315) is where they parted, and
|
* not [tagInk].
|
||||||
* moving the reminder with it would have silently broken that mirror instead.
|
|
||||||
*/
|
*/
|
||||||
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
|
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The colour a `#tag` is drawn in — in the note's own words, or as a chip.
|
* 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
|
* A tag whose text is in the body is tinted in place rather than repeated as a
|
||||||
* printing every tag twice — once where it was typed, once at the top). It is
|
* chip: the thing you see IS the thing you would delete to remove the tag.
|
||||||
* 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
|
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
|
||||||
|
|
||||||
@@ -94,7 +91,7 @@ data class NoteTint(
|
|||||||
* The fill only tints a shape the edge is drawing.
|
* 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
|
* 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
|
* shape would be the loudest thing on a board whose whole point is that the tag
|
||||||
* is the one coloured thing on it.
|
* is the one coloured thing on it.
|
||||||
*/
|
*/
|
||||||
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
|
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
|
||||||
@@ -102,18 +99,9 @@ data class NoteTint(
|
|||||||
|
|
||||||
// How strongly a chip's edge is drawn, as a fraction of its own ink.
|
// 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
|
// SOLVED FOR, NOT GUESSED. The edge is the ink at alpha over the known card fill, and
|
||||||
// picked against the worst case of the time: a chip on a card of its OWN colour, back
|
// 0.65 is the smallest alpha clearing WCAG 1.4.11's 3:1 for all ten hues: 3.03-4.36 in
|
||||||
// when a note took its first tag's fill. It gave 2.32:1 there, missed the 3:1 of WCAG
|
// light, 4.52-5.76 in dark. 0.60 misses for six of the ten in light.
|
||||||
// 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.
|
// Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring.
|
||||||
private const val CHIP_EDGE_ALPHA = 0.65f
|
private const val CHIP_EDGE_ALPHA = 0.65f
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ fun Notice(
|
|||||||
/**
|
/**
|
||||||
* One row of a dropdown menu, closing the menu before it acts.
|
* 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
|
* Lives here rather than beside either menu because there are two — the
|
||||||
* editor's overflow and the board's long-press menu — and they offer the same
|
* 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
|
* actions in the same words. A second copy of this would be a second place for the
|
||||||
* closing order to be got wrong.
|
* closing order to be got wrong.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.material3.TextFieldColors
|
|||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -27,10 +29,9 @@ import androidx.compose.ui.text.input.VisualTransformation
|
|||||||
* container and the indicator at each site independently is how they drift apart, so
|
* container and the indicator at each site independently is how they drift apart, so
|
||||||
* it happens once, here.
|
* it happens once, here.
|
||||||
*
|
*
|
||||||
* The note EDITOR no longer comes through this. It dropped to `BasicTextField`
|
* The note EDITOR doesn't use this: it uses `BasicTextField` (see `EditorBlock.kt`)
|
||||||
* (see `EditorBlock.kt`) for density: Material's field puts 16dp above and below its
|
* for density, since Material's field puts 16dp above and below its text, which is
|
||||||
* text, which is right for a form and is the whole row height on a checklist. Nothing
|
* right for a form and is the whole row height on a checklist.
|
||||||
* about "no box" was lost there — BasicTextField never had one.
|
|
||||||
*
|
*
|
||||||
* The disabled colours are stripped too: a trashed note is shown through this
|
* The disabled colours are stripped too: a trashed note is shown through this
|
||||||
* field read-only, and Material's disabled treatment would grey out text the user
|
* field read-only, and Material's disabled treatment would grey out text the user
|
||||||
@@ -54,7 +55,7 @@ fun PlainTextField(
|
|||||||
TextField(
|
TextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth().arrowKeysMoveCaret(atTop = { singleLine }, atBottom = { singleLine }),
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
placeholder = hint?.let { { Text(stringResource(it)) } },
|
placeholder = hint?.let { { Text(stringResource(it)) } },
|
||||||
singleLine = singleLine,
|
singleLine = singleLine,
|
||||||
@@ -86,3 +87,29 @@ private fun plainFieldColors(): TextFieldColors =
|
|||||||
unfocusedIndicatorColor = Color.Transparent,
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
disabledIndicatorColor = Color.Transparent,
|
disabledIndicatorColor = Color.Transparent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrow keys on a hardware keyboard move the caret, not the focus.
|
||||||
|
*
|
||||||
|
* Compose's text field gives the arrow keys of any device that reports D-pad keys
|
||||||
|
* to focus search before the field sees them, and a physical keyboard with arrow
|
||||||
|
* keys reports exactly that — so without this, ← and → jump to whatever sits beside
|
||||||
|
* the field instead of moving through the text. Cancelling the search in a
|
||||||
|
* direction hands the key back: the search reports that nothing moved, and the
|
||||||
|
* field takes the key as editing.
|
||||||
|
*
|
||||||
|
* ← and → never leave the field. ↑ and ↓ move between its lines, and from the first
|
||||||
|
* or last line — [atTop], [atBottom] — on to the field above or below, the way a
|
||||||
|
* caret leaves one paragraph for the next. Both are asked at the moment of the key
|
||||||
|
* press, so they can read where the caret is now.
|
||||||
|
*/
|
||||||
|
fun Modifier.arrowKeysMoveCaret(
|
||||||
|
atTop: () -> Boolean,
|
||||||
|
atBottom: () -> Boolean,
|
||||||
|
): Modifier =
|
||||||
|
focusProperties {
|
||||||
|
left = FocusRequester.Cancel
|
||||||
|
right = FocusRequester.Cancel
|
||||||
|
if (!atTop()) up = FocusRequester.Cancel
|
||||||
|
if (!atBottom()) down = FocusRequester.Cancel
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import com.fabledsword.inkwell.core.Label
|
|||||||
import com.fabledsword.inkwell.core.Note
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The drawer lists the few tags in use rather than every tag (#5931), so a long
|
* The drawer lists the few tags in use rather than every tag, so a long
|
||||||
* list never pushes Archive, Trash and Sync out of reach. Mirrors
|
* list never pushes Archive, Trash and Sync out of reach. Mirrors
|
||||||
* `frontend/src/notes/recentTags.ts`.
|
* `frontend/src/notes/recentTags.ts`.
|
||||||
*/
|
*/
|
||||||
@@ -59,7 +59,7 @@ private fun String?.millis(): Long = this?.let(::epochMillis) ?: 0L
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* When each tag's lens was last opened here. Opening one counts as using it,
|
* When each tag's lens was last opened here. Opening one counts as using it,
|
||||||
* however it was opened: from the drawer, or a tag on a card (#5931).
|
* however it was opened: from the drawer, or a tag on a card.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun rememberTagViews(destination: Destination): Map<String, Long> {
|
internal fun rememberTagViews(destination: Destination): Map<String, Long> {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ data class ShareState(
|
|||||||
val noteId: String? = null,
|
val noteId: String? = null,
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val members: List<Member> = emptyList(),
|
val members: List<Member> = emptyList(),
|
||||||
/** The groups the admin made (#5177); a note shared with one reaches its members. */
|
/** The groups the admin made; a note shared with one reaches its members. */
|
||||||
val groups: List<ShareGroup> = emptyList(),
|
val groups: List<ShareGroup> = emptyList(),
|
||||||
val shares: List<NoteShare> = emptyList(),
|
val shares: List<NoteShare> = emptyList(),
|
||||||
/** This device has no server, and sharing is between people on one. */
|
/** This device has no server, and sharing is between people on one. */
|
||||||
@@ -65,7 +65,7 @@ private fun targetOf(
|
|||||||
): ShareTarget = group?.let { ShareTarget.Group(it.id) } ?: ShareTarget.Member(member?.id.orEmpty())
|
): ShareTarget = group?.let { ShareTarget.Group(it.id) } ?: ShareTarget.Member(member?.id.orEmpty())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sharing a note with other people on the linked server (#5175).
|
* Sharing a note with other people on the linked server.
|
||||||
*
|
*
|
||||||
* A peer of the web's `ShareDialog.vue`. Shares belong to the server, so every call
|
* A peer of the web's `ShareDialog.vue`. Shares belong to the server, so every call
|
||||||
* here is a network round-trip through the core — `suspend` functions on uniffi's
|
* here is a network round-trip through the core — `suspend` functions on uniffi's
|
||||||
|
|||||||
@@ -118,12 +118,11 @@ fun SyncScreen(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
|
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
|
||||||
* bottom of its rail (#3181).
|
* bottom of its rail.
|
||||||
*
|
*
|
||||||
* Note 3127 §5 is why it is here at all. With version tags gone, an artifact's own
|
* With no version tags, an artifact's own self-report is the only answer to "which
|
||||||
* self-report is the only answer to "which build is this?" — so it renders
|
* build is this?", so it renders "unknown" rather than nothing when the name is
|
||||||
* "unknown" rather than nothing when the name is absent, because a blank line looks
|
* absent: a blank line looks like a layout bug.
|
||||||
* like a layout bug and a plausible default cannot be caught by anything.
|
|
||||||
*
|
*
|
||||||
* The read itself is `installedVersionName()`, shared with the client header the
|
* 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
|
* app sends its server: one answer to "which build is on this phone", so the line
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ enum class LinkMode { PASSWORD, TOKEN }
|
|||||||
* A sealed type rather than six loose strings, and not only for the parameter
|
* A sealed type rather than six loose strings, and not only for the parameter
|
||||||
* count: the two modes genuinely carry different things. `link_with_token` takes
|
* count: the two modes genuinely carry different things. `link_with_token` takes
|
||||||
* NO device name — the token was already minted against a named device in the web
|
* NO device name — the token was already minted against a named device in the web
|
||||||
* app — so a flat argument list meant collecting one in token mode and silently
|
* app — so a flat argument list would collect one in token mode and silently drop
|
||||||
* dropping it. Modelling it this way made that impossible to express.
|
* it.
|
||||||
*/
|
*/
|
||||||
sealed interface Credentials {
|
sealed interface Credentials {
|
||||||
data class Password(
|
data class Password(
|
||||||
@@ -112,7 +112,7 @@ data class SyncState(
|
|||||||
* mid-sync. That is safe by construction rather than by luck: no async path in the
|
* mid-sync. That is safe by construction rather than by luck: no async path in the
|
||||||
* core holds the store lock across an await, and `last_sync_at` is stamped only
|
* core holds the store lock across an await, and `last_sync_at` is stamped only
|
||||||
* after both halves of a cycle succeed, so an interrupted sync resumes from the
|
* after both halves of a cycle succeed, so an interrupted sync resumes from the
|
||||||
* stored cursor next time (Scribe #2736).
|
* stored cursor next time.
|
||||||
*/
|
*/
|
||||||
class SyncViewModel(
|
class SyncViewModel(
|
||||||
private val core: Inkwell,
|
private val core: Inkwell,
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ private sealed interface TagDialog {
|
|||||||
* because a desktop has room to float one over the board; on a phone this is a
|
* 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.
|
* 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
|
* It is also the ONLY colour control in the product, which is why the swatch is a
|
||||||
* control in the product. That is why the swatch is a first-class tap target on
|
* first-class tap target on every row rather than something behind the overflow
|
||||||
* every row rather than something behind the overflow menu.
|
* menu.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -289,9 +289,9 @@ private fun NewTagField(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Said out loud rather than left as a blank screen — and it names the `#` route,
|
* 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
|
* typing `#tag` in a note is the easiest way to make a tag, and easy not to know
|
||||||
* #2949) and this is the natural place to say so.
|
* about.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyTags() {
|
private fun EmptyTags() {
|
||||||
@@ -491,8 +491,7 @@ private fun MergeDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The palette, and since the per-note picker was removed this is the only place in
|
* The palette: the only place in the product a colour is chosen.
|
||||||
* the product a colour is chosen.
|
|
||||||
*
|
*
|
||||||
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is
|
* 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
|
* `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ data class TagsState(
|
|||||||
* ## Renaming can merge
|
* ## Renaming can merge
|
||||||
*
|
*
|
||||||
* `rename_label` folds two tags together when the new name is one another tag
|
* `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
|
* already holds, and the OLDER row survives. So it can return a
|
||||||
* tag whose id is not the one passed in, and it can make another tag stop
|
* 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
|
* existing. The screen asks first; this view model does not, because a
|
||||||
* confirmation belongs to the surface with a person in front of it.
|
* confirmation belongs to the surface with a person in front of it.
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ private val DarkColors =
|
|||||||
/**
|
/**
|
||||||
* Material 3 in Inkwell's own colours, following the system light/dark setting.
|
* Material 3 in Inkwell's own colours, following the system light/dark setting.
|
||||||
*
|
*
|
||||||
* DELIBERATELY NOT Material You dynamic colour, which this used until the operator
|
* DELIBERATELY NOT Material You dynamic colour. Dynamic colour is the more
|
||||||
* saw the first build. Dynamic colour is the more Android-native choice and it
|
* Android-native choice and it
|
||||||
* makes the app look like a different product on the phone than on the desktop and
|
* makes the app look like a different product on the phone than on the desktop and
|
||||||
* the web — on a stock device with no wallpaper it renders as undifferentiated
|
* the web — on a stock device with no wallpaper it renders as undifferentiated
|
||||||
* grey. The three surfaces are peers held to one quality bar, so they share one
|
* grey. The three surfaces are peers held to one quality bar, so they share one
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ fun UpdateCard(
|
|||||||
// Re-read on every return to the app, as ReminderNotice does: the grant is given
|
// Re-read on every return to the app, as ReminderNotice does: the grant is given
|
||||||
// in a system screen this app cannot observe. Read once, the warning would still
|
// in a system screen this app cannot observe. Read once, the warning would still
|
||||||
// be up after someone granted it and came back, and the offer below it would
|
// be up after someone granted it and came back, and the offer below it would
|
||||||
// look blocked when it is not (family idea #5103, practice 9).
|
// look blocked when it is not.
|
||||||
var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) }
|
var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) }
|
||||||
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) })
|
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) })
|
||||||
|
|
||||||
@@ -125,11 +125,8 @@ fun UpdateCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The nag: an update is waiting, said where someone will actually see it.
|
* The nag: an update is waiting, said where someone will actually see it rather than
|
||||||
*
|
* only on the sync screen.
|
||||||
* 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
|
* 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
|
* than the start of a wait — and so nothing is said at all until the app has been on
|
||||||
@@ -175,8 +172,7 @@ private val BANNER_SPINNER = 18.dp
|
|||||||
fun UnlinkedUpdateNote() {
|
fun UnlinkedUpdateNote() {
|
||||||
Hint(
|
Hint(
|
||||||
text = stringResource(R.string.update_needs_server),
|
text = stringResource(R.string.update_needs_server),
|
||||||
// Carries the bottom breathing room the connect button used to provide,
|
// The bottom breathing room: this is the last thing on the unlinked screen.
|
||||||
// now that it is the last thing on the unlinked screen.
|
|
||||||
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ data class UpdateState(
|
|||||||
* **Linked-only, and said out loud.** The app is local-first and completely usable
|
* **Linked-only, and said out loud.** The app is local-first and completely usable
|
||||||
* having never touched a server, so an unlinked install has no update path at all.
|
* having never touched a server, so an unlinked install has no update path at all.
|
||||||
* The card says that rather than offering a Check button that silently finds
|
* The card says that rather than offering a Check button that silently finds
|
||||||
* nothing — the same lesson as the desktop's unlink copy (issue 2110).
|
* nothing.
|
||||||
*
|
*
|
||||||
* The core does the network work, not this class: the device token lives in the
|
* The core does the network work, not this class: the device token lives in the
|
||||||
* Rust store and pulling it into Kotlin to make an HTTP call would spread the one
|
* Rust store and pulling it into Kotlin to make an HTTP call would spread the one
|
||||||
@@ -89,9 +89,8 @@ class UpdateViewModel(
|
|||||||
/**
|
/**
|
||||||
* The automatic path: look, fetch, then nag.
|
* The automatic path: look, fetch, then nag.
|
||||||
*
|
*
|
||||||
* Called when the app comes forward. Until this existed an update was only ever
|
* Called when the app comes forward, so an update is found without anyone opening
|
||||||
* found by someone opening the sync screen and pressing a button — so the ones
|
* the sync screen to look.
|
||||||
* that mattered were the ones nobody went looking for.
|
|
||||||
*
|
*
|
||||||
* Skipped when a check is already in flight, when a build is already waiting, and
|
* 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.
|
* when one ran recently: flicking between two apps is not a request to re-check.
|
||||||
@@ -106,8 +105,7 @@ class UpdateViewModel(
|
|||||||
|
|
||||||
// Already fetched and waved away — say so again. "Later" is for that
|
// Already fetched and waved away — say so again. "Later" is for that
|
||||||
// sitting, not forever, and without this branch a single dismissal would
|
// sitting, not forever, and without this branch a single dismissal would
|
||||||
// silence the update permanently. Which is precisely the "lost" this whole
|
// silence the update permanently.
|
||||||
// path exists to prevent.
|
|
||||||
state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false)
|
state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false)
|
||||||
|
|
||||||
// Found one and never fetched it — almost always because the last look
|
// Found one and never fetched it — almost always because the last look
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
<string name="editor_share">Share…</string>
|
<string name="editor_share">Share…</string>
|
||||||
|
|
||||||
<!-- Sharing (#5175). Shares live on the server, so the sheet says so when this
|
<!-- Sharing. Shares live on the server, so the sheet says so when this
|
||||||
phone isn't linked to one. -->
|
phone isn't linked to one. -->
|
||||||
<string name="share_title">Share</string>
|
<string name="share_title">Share</string>
|
||||||
<string name="share_loading">Loading…</string>
|
<string name="share_loading">Loading…</string>
|
||||||
@@ -116,6 +116,10 @@
|
|||||||
<string name="attach_not_here">This file hasn\'t downloaded to this phone yet. Sync, then try again.</string>
|
<string name="attach_not_here">This file hasn\'t downloaded to this phone yet. Sync, then try again.</string>
|
||||||
<string name="attach_no_app">No app on this phone opens this kind of file.</string>
|
<string name="attach_no_app">No app on this phone opens this kind of file.</string>
|
||||||
<string name="attach_open_failed">Couldn\'t open the file: %1$s</string>
|
<string name="attach_open_failed">Couldn\'t open the file: %1$s</string>
|
||||||
|
<string name="attach_view">Open full size</string>
|
||||||
|
<string name="attach_copy">Copy image</string>
|
||||||
|
<string name="attach_copied">Image copied</string>
|
||||||
|
<string name="attach_copy_failed">Couldn\'t copy the image: %1$s</string>
|
||||||
<string name="preview_remove">Remove link preview</string>
|
<string name="preview_remove">Remove link preview</string>
|
||||||
|
|
||||||
<!-- Deleting for good is the only thing in the app that cannot be undone, so
|
<!-- Deleting for good is the only thing in the app that cannot be undone, so
|
||||||
@@ -130,7 +134,7 @@
|
|||||||
who it happens in. -->
|
who it happens in. -->
|
||||||
<string name="capture_process_text">New note</string>
|
<string name="capture_process_text">New note</string>
|
||||||
|
|
||||||
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
|
<!-- Tag management. The whole vocabulary is "tag"; the
|
||||||
schema still says Label, and no string here needs to know that. -->
|
schema still says Label, and no string here needs to know that. -->
|
||||||
<string name="tags_manage">Manage tags</string>
|
<string name="tags_manage">Manage tags</string>
|
||||||
<string name="tags_show_all">All tags (%1$d)</string>
|
<string name="tags_show_all">All tags (%1$d)</string>
|
||||||
@@ -153,9 +157,9 @@
|
|||||||
<string name="tags_rename">Rename</string>
|
<string name="tags_rename">Rename</string>
|
||||||
<string name="tags_rename_title">Rename %1$s</string>
|
<string name="tags_rename_title">Rename %1$s</string>
|
||||||
<string name="tags_rename_confirm">Rename</string>
|
<string name="tags_rename_confirm">Rename</string>
|
||||||
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
|
<!-- Renaming onto an existing tag merges the two, older survives. A merge
|
||||||
#3324). A merge cannot be undone by repeating it and is reachable here by
|
cannot be undone by repeating it and is reachable here by a typo, so it
|
||||||
a typo, so it says so before it happens — same reasoning as #2116. -->
|
says so before it happens. -->
|
||||||
<string name="tags_rename_merges_title">Merge with %1$s?</string>
|
<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_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_rename_merges_confirm">Merge</string>
|
||||||
|
|||||||
@@ -12,11 +12,8 @@ import org.junit.Test
|
|||||||
* writes them out by hand, so changing the hash or the key order means changing the
|
* writes them out by hand, so changing the hash or the key order means changing the
|
||||||
* fixture and this file together, or one of the two suites goes red.
|
* fixture and this file together, or one of the two suites goes red.
|
||||||
*
|
*
|
||||||
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and
|
* The four UUID hashes pin the hash ITSELF; colours are derived only from a tag's
|
||||||
* the resolution order that chose between a picked colour, a tag's and a generated one
|
* name.
|
||||||
* — 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 {
|
class DerivedTintTest {
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -3,20 +3,17 @@ name = "inkwell-uniffi-bindgen"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Generates the Kotlin bindings for inkwell-ffi"
|
description = "Generates the Kotlin bindings for inkwell-ffi"
|
||||||
authors = ["bvandeusen"]
|
authors = ["bvandeusen"]
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# A crate whose ONLY dependency is uniffi itself.
|
# A crate whose ONLY dependency is uniffi itself.
|
||||||
#
|
#
|
||||||
# This started life as a `[[bin]]` inside inkwell-ffi, which failed: building
|
# Not a `[[bin]]` inside inkwell-ffi: building that compiles the core, reqwest,
|
||||||
# it compiled that crate and therefore the core, reqwest, native-tls and
|
# native-tls and openssl-sys for the HOST, and the vendored-OpenSSL block in
|
||||||
# openssl-sys — for the HOST. The vendored-OpenSSL block in core/Cargo.toml is
|
# core/Cargo.toml is scoped to `cfg(target_os = "android")`, so a host build
|
||||||
# scoped to `cfg(target_os = "android")`, so a host build looks for a system
|
# looks for a system OpenSSL that ci-rust-android doesn't carry. A code
|
||||||
# OpenSSL that ci-rust-android has no reason to carry, and the generator died
|
# generator should not link the app's TLS stack to emit Kotlin; on its own it
|
||||||
# with "failed to run custom build command for openssl-sys".
|
# compiles ~15 small crates.
|
||||||
#
|
|
||||||
# Adding libssl-dev to the image would have worked and been wrong: a code
|
|
||||||
# generator should not link the app's TLS stack to emit Kotlin. Splitting it out
|
|
||||||
# means the generator compiles ~15 small crates and nothing else.
|
|
||||||
#
|
#
|
||||||
# Still a WORKSPACE MEMBER, deliberately. That is what keeps `uniffi` here and
|
# Still a WORKSPACE MEMBER, deliberately. That is what keeps `uniffi` here and
|
||||||
# `uniffi` linked into the .so on one version from one lockfile — they are two
|
# `uniffi` linked into the .so on one version from one lockfile — they are two
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
// kotlin-android is NOT registered: AGP 9 enables built-in Kotlin, and the
|
// kotlin-android is NOT registered: AGP 9 enables built-in Kotlin, and the
|
||||||
// older plugin can't cast AGP 9's ApplicationExtension to the removed
|
// older plugin can't cast AGP 9's ApplicationExtension to the BaseExtension
|
||||||
// BaseExtension. Same conclusion Minstrel reached on this toolchain pair.
|
// AGP 9 no longer has.
|
||||||
alias(libs.plugins.compose.compiler) apply false
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
// ktlint/detekt are run from the CI image's pinned CLIs, not as Gradle
|
// ktlint/detekt are run from the CI image's pinned CLIs, not as Gradle
|
||||||
// plugins — see the note in gradle/libs.versions.toml.
|
// plugins — see the note in gradle/libs.versions.toml.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Per-rule overrides layered on top of detekt's defaults
|
# Per-rule overrides layered on top of detekt's defaults
|
||||||
# (`--build-upon-default-config` on the CLI invocation in the Android lane).
|
# (`--build-upon-default-config` on the CLI invocation in the Android lane).
|
||||||
#
|
#
|
||||||
# The pre-2.0 `build:` top-level was removed; failure is controlled by the CLI's
|
# Failure is controlled by the CLI's exit code; detekt 2 has no `build:`
|
||||||
# exit code instead.
|
# top-level.
|
||||||
|
|
||||||
naming:
|
naming:
|
||||||
# Composables conventionally use PascalCase function names. Matches every
|
# Composables conventionally use PascalCase function names. Matches every
|
||||||
@@ -49,7 +49,7 @@ complexity:
|
|||||||
|
|
||||||
exceptions:
|
exceptions:
|
||||||
TooGenericExceptionCaught:
|
TooGenericExceptionCaught:
|
||||||
# Catching broadly is DELIBERATE in these two places, and each site says so.
|
# Catching broadly is DELIBERATE in these places, and each site says so.
|
||||||
#
|
#
|
||||||
# * the ViewModel — a note that fails to save must become a visible error
|
# * the ViewModel — a note that fails to save must become a visible error
|
||||||
# banner, never a crash. Narrowing this would mean an unanticipated
|
# banner, never a crash. Narrowing this would mean an unanticipated
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name = "inkwell-ffi"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "uniffi bindings exposing inkwell-core to the native Android client"
|
description = "uniffi bindings exposing inkwell-core to the native Android client"
|
||||||
authors = ["bvandeusen"]
|
authors = ["bvandeusen"]
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
+16
-21
@@ -3,7 +3,7 @@
|
|||||||
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
|
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
|
||||||
//! — a thin shim over the shared core, holding no logic of its own. If something here
|
//! — a thin shim over the shared core, holding no logic of its own. If something here
|
||||||
//! starts making decisions about notes or sync, it belongs in the core where the
|
//! starts making decisions about notes or sync, it belongs in the core where the
|
||||||
//! desktop gets it too (Scribe note 2730).
|
//! desktop gets it too.
|
||||||
//!
|
//!
|
||||||
//! ## Shape
|
//! ## Shape
|
||||||
//!
|
//!
|
||||||
@@ -30,9 +30,7 @@
|
|||||||
//! The Android build overrides it back to `unwind` (`CargoNdkBuild` in
|
//! The Android build overrides it back to `unwind` (`CargoNdkBuild` in
|
||||||
//! `app/build.gradle.kts`), so uniffi catches a panic crossing the FFI boundary and
|
//! `app/build.gradle.kts`), so uniffi catches a panic crossing the FFI boundary and
|
||||||
//! raises it in Kotlin as an exception, which the board shows as an error. Under
|
//! raises it in Kotlin as an exception, which the board shows as an error. Under
|
||||||
//! `abort` the same panic would end the app. Every phone build had unwind before
|
//! `abort` the same panic would end the app. The cost is library size.
|
||||||
//! the release profile was used (debug unwinds), so keeping it changes nothing a
|
|
||||||
//! phone does. The cost is library size (Scribe #2810).
|
|
||||||
|
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|
||||||
@@ -105,7 +103,7 @@ impl CoreError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Seals the device token before it is stored, and opens it when it is read: Kotlin's
|
/// Seals the device token before it is stored, and opens it when it is read: Kotlin's
|
||||||
/// half of [`state::TokenSeal`] (family idea #5105, practice 12).
|
/// half of [`state::TokenSeal`].
|
||||||
///
|
///
|
||||||
/// Implemented with a key in the Android Keystore. The key never leaves the phone,
|
/// Implemented with a key in the Android Keystore. The key never leaves the phone,
|
||||||
/// so a copy of the app's files carries a token nothing else can open. That includes
|
/// so a copy of the app's files carries a token nothing else can open. That includes
|
||||||
@@ -255,7 +253,7 @@ impl Inkwell {
|
|||||||
///
|
///
|
||||||
/// Returns nothing, as the board reloads its list afterwards anyway. The core marks
|
/// Returns nothing, as the board reloads its list afterwards anyway. The core marks
|
||||||
/// each note dirty so the new positions sync, and a note shared with this account
|
/// each note dirty so the new positions sync, and a note shared with this account
|
||||||
/// moves on this board only (#5176).
|
/// moves on this board only.
|
||||||
pub fn reorder_notes(&self, ordered_ids: Vec<String>) -> Result<(), CoreError> {
|
pub fn reorder_notes(&self, ordered_ids: Vec<String>) -> Result<(), CoreError> {
|
||||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
local::store::reorder(&conn, &ordered_ids).map_err(CoreError::store)
|
local::store::reorder(&conn, &ordered_ids).map_err(CoreError::store)
|
||||||
@@ -600,11 +598,11 @@ impl Inkwell {
|
|||||||
|
|
||||||
// ────────────────────────────── sharing ──────────────────────────────
|
// ────────────────────────────── sharing ──────────────────────────────
|
||||||
//
|
//
|
||||||
// The Share dialog asks the server directly (#5175). Unlinked, each answers
|
// The Share dialog asks the server directly. Unlinked, each answers
|
||||||
// `NotLinked`, which the dialog turns into "sharing needs a server".
|
// `NotLinked`, which the dialog turns into "sharing needs a server".
|
||||||
//
|
//
|
||||||
// Each passes the token `credentials()` OPENED. The stored one is sealed, and the
|
// Each passes the token `credentials()` OPENED. The stored one is sealed, and the
|
||||||
// server refuses `sealed:…` (#5381).
|
// server refuses `sealed:…`.
|
||||||
|
|
||||||
/// Everyone on the instance a note can be shared with, and every group.
|
/// Everyone on the instance a note can be shared with, and every group.
|
||||||
pub async fn share_directory(&self) -> Result<Directory, CoreError> {
|
pub async fn share_directory(&self) -> Result<Directory, CoreError> {
|
||||||
@@ -682,10 +680,8 @@ pub fn checklist_render(text: String, checked: bool) -> String {
|
|||||||
/// Tell the core which app it is running inside, and which build of it.
|
/// 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
|
/// 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
|
/// the desktop app. The honest value is the installed package's own `versionName`,
|
||||||
/// `inkwell-desktop` carrying the CORE crate's version — a number no build
|
/// which is what Kotlin passes here.
|
||||||
/// stamps and nobody has seen. The honest value is the installed package's own
|
|
||||||
/// `versionName`, which is what Kotlin passes here.
|
|
||||||
///
|
///
|
||||||
/// Called once from `InkwellApplication.onCreate`, before anything can sync.
|
/// Called once from `InkwellApplication.onCreate`, before anything can sync.
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
@@ -820,7 +816,7 @@ mod tests {
|
|||||||
.get_note(created.id.clone())
|
.get_note(created.id.clone())
|
||||||
.expect("get should succeed");
|
.expect("get should succeed");
|
||||||
assert_eq!(fetched.id, created.id);
|
assert_eq!(fetched.id, created.id);
|
||||||
// The NAME is the first line — there is no title field to have set (M13 step 3).
|
// The NAME is the first line — there is no title field to have set.
|
||||||
assert_eq!(fetched.display_title, "Groceries");
|
assert_eq!(fetched.display_title, "Groceries");
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
@@ -1041,9 +1037,8 @@ mod tests {
|
|||||||
|
|
||||||
/// The path the notification's Done button takes.
|
/// The path the notification's Done button takes.
|
||||||
///
|
///
|
||||||
/// Completing a RECURRING reminder must move it, not end it — this is the
|
/// Completing a RECURRING reminder must move it, not end it, as on the web;
|
||||||
/// behaviour the web has had all along and the clients did not, which made
|
/// otherwise "Done" on a daily reminder would be the last time it ever fired.
|
||||||
/// "Done" on a daily reminder quietly the last time it ever fired.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
@@ -1103,9 +1098,9 @@ mod tests {
|
|||||||
/// Renaming a tag onto a name another tag already holds MERGES the two, and the
|
/// Renaming a tag onto a name another tag already holds MERGES the two, and the
|
||||||
/// OLDER row is the survivor.
|
/// OLDER row is the survivor.
|
||||||
///
|
///
|
||||||
/// Before this, the bare UPDATE met `idx_labels_name` — unique on `lower(name)` —
|
/// A bare UPDATE would meet `idx_labels_name` (unique on `lower(name)`) and hand
|
||||||
/// and the user got a raw "UNIQUE constraint failed" from SQLite. Merging is what
|
/// the user a raw "UNIQUE constraint failed"; merging is what a person means by
|
||||||
/// a person means by typing an existing tag's name onto this one.
|
/// typing an existing tag's name onto this one.
|
||||||
#[test]
|
#[test]
|
||||||
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
|
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
@@ -1215,8 +1210,8 @@ mod tests {
|
|||||||
(url, handle)
|
(url, handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #5381: the token is stored sealed, so a share call has to send the OPENED one.
|
/// The token is stored sealed, so a share call has to send the OPENED one; the
|
||||||
/// It used to read the store itself and send `sealed:…`, which the server refused.
|
/// server refuses `sealed:…`.
|
||||||
#[test]
|
#[test]
|
||||||
fn sharing_sends_the_opened_token_not_the_stored_one() {
|
fn sharing_sends_the_opened_token_not_the_stored_one() {
|
||||||
let (url, server) = one_reply(r#"{"members":[],"groups":[]}"#);
|
let (url, server) = one_reply(r#"{"members":[],"groups":[]}"#);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ pub struct Note {
|
|||||||
pub previews: Vec<LinkPreview>,
|
pub previews: Vec<LinkPreview>,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
/// How this account holds the note (#5175): "owner", or "edit"/"view" for one
|
/// How this account holds the note: "owner", or "edit"/"view" for one
|
||||||
/// someone shared with it.
|
/// someone shared with it.
|
||||||
pub permission: String,
|
pub permission: String,
|
||||||
/// Whether the owner has shared it with anyone.
|
/// Whether the owner has shared it with anyone.
|
||||||
@@ -335,7 +335,7 @@ pub struct Label {
|
|||||||
/// matching the REST single-label responses.
|
/// matching the REST single-label responses.
|
||||||
pub count: Option<i64>,
|
pub count: Option<i64>,
|
||||||
/// The newest edit among the untrashed notes carrying it, as the store writes
|
/// The newest edit among the untrashed notes carrying it, as the store writes
|
||||||
/// timestamps; None when nothing does. Ranks the drawer's recent tags (#5931).
|
/// timestamps; None when nothing does. Ranks the drawer's recent tags.
|
||||||
pub last_used: Option<String>,
|
pub last_used: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +424,7 @@ impl From<NoteFacets> for core_models::Facets {
|
|||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct NoteDraft {
|
pub struct NoteDraft {
|
||||||
pub body: String,
|
pub body: String,
|
||||||
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
|
/// Checklist lines. A note can carry both a body and items, so this
|
||||||
/// is not an alternative to `body` — it is an addition to it.
|
/// is not an alternative to `body` — it is an addition to it.
|
||||||
pub items: Option<Vec<String>>,
|
pub items: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
@@ -616,7 +616,7 @@ impl From<core_client::Identity> for Identity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Someone on the instance a note can be shared with (#5175).
|
/// Someone on the instance a note can be shared with.
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct Member {
|
pub struct Member {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -639,7 +639,7 @@ impl From<core_client::Member> for Member {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A group the admin made, which a note can be shared with (#5177).
|
/// A group the admin made, which a note can be shared with.
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct ShareGroup {
|
pub struct ShareGroup {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# --enable-native-access=ALL-UNNAMED silences the JDK 22+ "restricted method in
|
# --enable-native-access=ALL-UNNAMED silences the JDK 22+ "restricted method in
|
||||||
# java.lang.System has been called" warning that Gradle 9.1's bundled
|
# java.lang.System has been called" warning that Gradle 9.1's bundled
|
||||||
# native-platform jar trips via System.load(). Same opt-in Minstrel needs on the
|
# native-platform jar trips via System.load(); future JDKs promote the warning
|
||||||
# same Gradle/JDK pair; future JDKs promote the warning to an error.
|
# to an error.
|
||||||
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED
|
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED
|
||||||
org.gradle.parallel=true
|
org.gradle.parallel=true
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
@@ -9,8 +9,8 @@ org.gradle.configuration-cache=true
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
|
||||||
# Matches Minstrel: detekt 2.0-alpha and the ktlint Gradle plugin still have
|
# detekt 2.0-alpha and the ktlint Gradle plugin have intermittent
|
||||||
# intermittent configuration-cache holes. Warn rather than fail so the CC speedup
|
# configuration-cache holes. Warn rather than fail so the CC speedup applies
|
||||||
# applies where it can.
|
# where it can.
|
||||||
org.gradle.configuration-cache.problems=warn
|
org.gradle.configuration-cache.problems=warn
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
# Pinned as a MATRIX, matching Minstrel's proven combination on the same JDK:
|
# Pinned as a MATRIX:
|
||||||
# - Gradle 9.1.0 supports JDK 25 (see gradle-wrapper.properties)
|
# - Gradle 9.1.0 supports JDK 25 (see gradle-wrapper.properties)
|
||||||
# - AGP 9.0.1 requires Gradle 9.1.0+
|
# - AGP 9.0.1 requires Gradle 9.1.0+
|
||||||
# - Kotlin 2.3.x is AGP 9's built-in Kotlin path
|
# - Kotlin 2.3.x is AGP 9's built-in Kotlin path
|
||||||
@@ -14,16 +14,13 @@ coroutines = "1.9.0"
|
|||||||
|
|
||||||
# WorkManager runs the background sync. `work-runtime-ktx` is NOT used: as of
|
# WorkManager runs the background sync. `work-runtime-ktx` is NOT used: as of
|
||||||
# 2.11 it is a 6 KB stub and every Kotlin extension (`PeriodicWorkRequestBuilder`,
|
# 2.11 it is a 6 KB stub and every Kotlin extension (`PeriodicWorkRequestBuilder`,
|
||||||
# `CoroutineWorker`) has moved into `work-runtime` itself. Verified by unpacking
|
# `CoroutineWorker`) has moved into `work-runtime` itself.
|
||||||
# both artifacts, not from memory.
|
|
||||||
work = "2.11.2"
|
work = "2.11.2"
|
||||||
|
|
||||||
# ktlint and detekt are NOT Gradle plugins here. ci-rust-android already ships
|
# ktlint and detekt are NOT Gradle plugins here. ci-rust-android ships both as
|
||||||
# both as pinned CLIs (M12 step 3), and the CI lane invokes those directly. Adding
|
# pinned CLIs, and the CI lane invokes those directly. The Gradle plugins would be
|
||||||
# the Gradle plugins would mean a SECOND pinned version of each tool, resolved at
|
# a SECOND pinned version of each tool, resolved at build time, that has to be
|
||||||
# build time, that has to be kept in lockstep with the image's by hand — and the
|
# kept in lockstep with the image's by hand.
|
||||||
# first attempt at it failed outright, because the detekt version Minstrel pins
|
|
||||||
# (2.0.0-alpha.3) is not published to Maven Central or the plugin portal at all.
|
|
||||||
|
|
||||||
# JNA is not optional: uniffi's Kotlin bindings call into the .so through it.
|
# JNA is not optional: uniffi's Kotlin bindings call into the .so through it.
|
||||||
# The @aar classifier matters — the plain jar has no Android native payload and
|
# The @aar classifier matters — the plain jar has no Android native payload and
|
||||||
@@ -32,7 +29,7 @@ jna = "5.14.0"
|
|||||||
|
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
|
|
||||||
# Coil draws a link preview's image (Scribe #3307), the first image this client
|
# Coil draws a link preview's image, the first image this client
|
||||||
# loads. Coil 3 ships its networking separately; the OkHttp fetcher is the one its
|
# loads. Coil 3 ships its networking separately; the OkHttp fetcher is the one its
|
||||||
# docs name as the default, and it registers itself, so no loader is configured.
|
# docs name as the default, and it registers itself, so no loader is configured.
|
||||||
# Held at 3.5.0: 3.6.0 moved to compileSdk 37 and Compose 1.12, which need AGP 9.1.
|
# Held at 3.5.0: 3.6.0 moved to compileSdk 37 and Compose 1.12, which need AGP 9.1.
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ def supplied_args(rest):
|
|||||||
"""Count top-level commas in an argument tail.
|
"""Count top-level commas in an argument tail.
|
||||||
|
|
||||||
Kotlin permits a TRAILING comma before the closing paren, which is not an
|
Kotlin permits a TRAILING comma before the closing paren, which is not an
|
||||||
argument — counting it inflated every multi-line call by one the first time
|
argument; counting it would inflate every multi-line call by one. Braces count
|
||||||
this was written, and made three correct call sites look broken. Braces count
|
|
||||||
toward depth as well as parens, or a comma inside a lambda would be read as
|
toward depth as well as parens, or a comma inside a lambda would be read as
|
||||||
another argument.
|
another argument.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Flag capitalised identifiers that are neither imported nor declared locally.
|
"""Flag capitalised identifiers that are neither imported nor declared locally.
|
||||||
|
|
||||||
This exists because ktlint and detekt are both structurally blind to it: they
|
ktlint and detekt parse Kotlin without resolving symbols, so a *missing import*
|
||||||
parse Kotlin without resolving symbols, so a *missing import* is invisible to
|
is invisible to them and both pass a file that cannot compile.
|
||||||
them and both pass a file that cannot compile. The first sync-screen push failed
|
|
||||||
in CI on exactly that (`Unresolved reference 'Build'` — `android.os.Build` was
|
|
||||||
lost in a file split), after a clean local analyzer run.
|
|
||||||
|
|
||||||
Not a type checker and not trying to be. `compileDebugKotlin` in CI is the real
|
Not a type checker and not trying to be. `compileDebugKotlin` in CI is the real
|
||||||
one; this is a cheap pre-push filter for the single mistake that survives every
|
one; this is a cheap pre-push filter for the single mistake that survives every
|
||||||
@@ -13,10 +10,8 @@ other local gate. It errs toward false positives — anything it cannot account
|
|||||||
for is reported rather than assumed fine.
|
for is reported rather than assumed fine.
|
||||||
|
|
||||||
It also checks MEMBERS of this package's own `object` declarations — `Foo.bar()`
|
It also checks MEMBERS of this package's own `object` declarations — `Foo.bar()`
|
||||||
where `Foo` is an object declared here. That case was added after moving a
|
where `Foo` is an object declared here — so a function moved between two objects
|
||||||
function between two objects and forgetting to paste it into the second: the
|
and missed at a call site is caught.
|
||||||
call site read `Other.thing()`, resolved fine as far as the leading token, and
|
|
||||||
failed in CI (785ebdb).
|
|
||||||
|
|
||||||
Still NOT caught, so a clean run is not over-read: members of anything declared
|
Still NOT caught, so a clean run is not over-read: members of anything declared
|
||||||
outside this package, members reached through a variable rather than a type
|
outside this package, members reached through a variable rather than a type
|
||||||
@@ -106,8 +101,7 @@ def object_members(src: str) -> dict:
|
|||||||
if depth == 0:
|
if depth == 0:
|
||||||
# Nested TYPES count as members too: `Foo.Bar` where Bar is a
|
# Nested TYPES count as members too: `Foo.Bar` where Bar is a
|
||||||
# data class inside object Foo is an ordinary reference, and
|
# data class inside object Foo is an ordinary reference, and
|
||||||
# leaving them out made the checker report four false positives
|
# leaving them out would report each one as unknown.
|
||||||
# the first time an object held one.
|
|
||||||
decl = re.match(
|
decl = re.match(
|
||||||
r"\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
|
r"\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
|
||||||
r"(?:const |lateinit |inline |suspend |data |sealed |enum |value |abstract |open )*"
|
r"(?:const |lateinit |inline |suspend |data |sealed |enum |value |abstract |open )*"
|
||||||
|
|||||||
+258
-374
@@ -3,317 +3,173 @@
|
|||||||
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
|
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
|
||||||
> in the CI-Runner repo.
|
> in the CI-Runner repo.
|
||||||
|
|
||||||
## Runtime image
|
## Runtime images
|
||||||
|
|
||||||
```
|
| Image | Used by |
|
||||||
git.fabledsword.com/bvandeusen/ci-python:3.14
|
|---|---|
|
||||||
```
|
| `git.fabledsword.com/bvandeusen/ci-python:3.14` | every `ci.yml` job; the `decide` jobs in `desktop.yml` and `android.yml` |
|
||||||
|
| `git.fabledsword.com/bvandeusen/ci-tauri:1.97` | `desktop.yml` `verify`, `build`, `manifest`; `android.yml` `rust` |
|
||||||
|
| `git.fabledsword.com/bvandeusen/ci-tauri-win:1.97` | `desktop.yml` `windows` |
|
||||||
|
| `git.fabledsword.com/bvandeusen/ci-rust-android:1.97` | `android.yml` `build`, `server-image` |
|
||||||
|
| `postgres:16-alpine` | service container for `ci.yml` `integration` |
|
||||||
|
|
||||||
Selected via `container.image` (not a `runs-on` label) on all four jobs in
|
Each is selected via `container.image`; `runs-on` is only a scheduling label.
|
||||||
`.forgejo/workflows/ci.yml`: typecheck (Vue/TS), lint (ruff), test (pytest),
|
|
||||||
build (docker buildx).
|
The Rust pin is in LOCKSTEP across `ci-tauri`, `ci-tauri-win` and `ci-rust-android`.
|
||||||
|
All three build `inkwell-core` from one workspace `Cargo.lock` under `--locked`, so a
|
||||||
|
mismatched Rust minor would mean divergent resolution for no reason. Bump them
|
||||||
|
together or not at all.
|
||||||
|
|
||||||
## Image deps used
|
## Image deps used
|
||||||
|
|
||||||
- python 3.12+ (the runtime `Dockerfile` targets python:3.12-slim; tests run on
|
**ci-python**
|
||||||
the image's 3.14 — both >=3.12, so results stay representative)
|
- python 3.14 for the tests. The runtime `Dockerfile` targets `python:3.12-slim`;
|
||||||
- node 24 — `npm ci` + `vue-tsc` in the typecheck job, and the frontend builder
|
both are >=3.12, so results stay representative.
|
||||||
stage inside the production `Dockerfile`. (Also required by the JS-based
|
- node 24 — `npm ci`, `vue-tsc` and the web unit tests. Also required by the JS-based
|
||||||
`actions/checkout` action — a Node-less runner fails every job at checkout.)
|
`actions/checkout`: a Node-less runner fails every job at checkout. The
|
||||||
- ruff — lint job runs `ruff check src/` with zero install overhead
|
`Dockerfile`'s own frontend stage uses `node:22-alpine`.
|
||||||
- uv — test job creates the venv (`uv venv /opt/venv`) and installs the package
|
- ruff — the lint job runs `ruff check src/` with no install.
|
||||||
with dev deps
|
- uv — the test jobs create the venv (`uv venv /opt/venv`) and install the package
|
||||||
- docker CLI + buildx — build job pushes the dev/release image to the Fabled-Git
|
with dev deps.
|
||||||
registry
|
- docker CLI + buildx — the build job pushes the image to the Fabled-Git registry.
|
||||||
|
- curl — `packaging/fetch-clients.sh` and `packaging/guard-forward.sh`. The guard
|
||||||
|
refuses to run without it, because every lookup would read as "nothing published".
|
||||||
|
|
||||||
|
**ci-tauri** — Rust, Node, WebKitGTK 4.1, the Tauri v2 Linux deps and `tauri-cli`.
|
||||||
|
- **`libssl-dev` + `pkg-config` are load-bearing.** `inkwell-core`, which the desktop
|
||||||
|
crate builds in, uses `reqwest` with the **`native-tls`** backend, which on Linux compiles against
|
||||||
|
OpenSSL. Dropping either fails the build at `openssl-sys`. They are also Tauri's
|
||||||
|
documented Linux prerequisites. This is why `android.yml`'s host-target `rust` job
|
||||||
|
runs here and not on `ci-rust-android`, whose OpenSSL is vendored for the Android
|
||||||
|
targets alone.
|
||||||
|
- `dpkg-deb`, `dpkg-query`, `apt-cache` and `dpkg-shlibdeps` (from `dpkg-dev`, pulled
|
||||||
|
in by `build-essential`) for `desktop/packaging/deb/verify.sh`; `tar` and a
|
||||||
|
compressor for `desktop/packaging/arch/package-prebuilt.sh`.
|
||||||
|
- Optional, used when present: `bsdtar` (`libarchive-tools`) for the pacman package's
|
||||||
|
`.MTREE`, `zstd` (compression falls back zstd → xz → gzip), and a docker CLI for the
|
||||||
|
`.deb` clean-container install test. `ci-tauri` has the first two, so the package
|
||||||
|
ships as `.pkg.tar.zst` with an `.MTREE`. It has no docker CLI, so the install test
|
||||||
|
is skipped; adding one would enable it, and its absence does not block a green
|
||||||
|
build.
|
||||||
|
|
||||||
|
**ci-tauri-win** — Rust, Node, `cargo-xwin`, LLVM/`lld` and NSIS. A separate image
|
||||||
|
per CI-Runner's fork rule: the MSVC CRT/SDK cache alone is >1 GB.
|
||||||
|
|
||||||
|
**ci-rust-android** — Rust, the four Android ABIs, `cargo-ndk`, SDK/NDK, JDK 25,
|
||||||
|
`ktlint` and `detekt`. The NDK binutils symlinks and PATH append are there because NDK
|
||||||
|
r23 removed the triple-prefixed binutils that autotools, and so vendored OpenSSL,
|
||||||
|
invokes by bare name. JDK 25 requires **Gradle 9.1+** in the wrapper. The analyzers
|
||||||
|
are the image's pinned CLIs rather than Gradle plugins, so there is one version of
|
||||||
|
each.
|
||||||
|
|
||||||
## Per-job tool installs
|
## Per-job tool installs
|
||||||
|
|
||||||
Nothing installed at job time beyond what the image provides — all four jobs run
|
None. Every job runs entirely on its image.
|
||||||
entirely on `ci-python:3.14`.
|
|
||||||
|
## The lanes
|
||||||
|
|
||||||
|
**`ci.yml`** — on every push to `dev` and `main`, and when dispatched.
|
||||||
|
`gate`, `typecheck`, `lint`, `test` and `integration` run in parallel, and `build`
|
||||||
|
needs all five, so nothing publishes on red. `dev` publishes `:dev`; `main` publishes
|
||||||
|
`:latest` and `:<sha>`. The image always builds: it is ~15 seconds, and building every
|
||||||
|
push picks up `python:3.12-slim` updates.
|
||||||
|
|
||||||
|
`gate` stands the build down when the push touched the Android client's file set.
|
||||||
|
The Android lane then dispatches `ci.yml` once it has published the APK, so the image
|
||||||
|
bakes in the client from the same commit.
|
||||||
|
|
||||||
|
**`desktop.yml`** — on every push to `dev` and `main`.
|
||||||
|
- `decide` reads the file set in `packaging/version.sh` and skips everything when the
|
||||||
|
channel already serves this source. `packaging/guard-forward.sh` runs here, so it
|
||||||
|
also covers the skip path.
|
||||||
|
- `verify` builds the frontend, runs the web unit tests, then `cargo clippy --locked
|
||||||
|
--workspace`, `cargo test --workspace` and `cargo fmt --all --check` from the repo
|
||||||
|
root. Run from `desktop/src-tauri` instead, cargo would scope to the desktop package
|
||||||
|
and the core's tests would silently not run. If you add a workspace member, check
|
||||||
|
that it appears in the `cargo test` output.
|
||||||
|
- `build` (Linux): `cargo tauri build` for `.deb` and AppImage, de-bundles the
|
||||||
|
AppImage's graphics libs and re-signs it, verifies the `.deb`, repackages for pacman,
|
||||||
|
and publishes to the channel. `APPIMAGE_EXTRACT_AND_RUN=1` is set because CI
|
||||||
|
containers have no `/dev/fuse`.
|
||||||
|
- `windows`: cross-compiles the NSIS installer. There is no Windows build host, and a
|
||||||
|
Windows container cannot run on a Linux host. `.msi` needs WiX v3, a Windows
|
||||||
|
program, so NSIS only.
|
||||||
|
- `manifest` writes `latest.json` after both, so it describes both platforms.
|
||||||
|
|
||||||
|
**`android.yml`** — on every push to `dev` and `main`.
|
||||||
|
`decide`, then `rust` (the core's clippy and tests, which gate the APK), then `build`
|
||||||
|
(native library and bindings, ktlint, detekt, host-JVM unit tests, assemble, check
|
||||||
|
the signing certificate, publish), then `server-image`, which dispatches `ci.yml`.
|
||||||
|
|
||||||
|
**`release.yml`** — on a `v*` tag. Builds nothing; it writes the changelog for the
|
||||||
|
release.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **No `actions/cache`.** Deliberately omitted for npm/uv: it's a GitHub-fetched
|
- **No `actions/cache` for npm or uv.** It is a GitHub-fetched JS action, and on a
|
||||||
JS action and on a cold runner concurrent jobs race fetching it. We lean on the
|
cold runner concurrent jobs race fetching it. The pinned images' toolchains carry
|
||||||
pinned `ci-python` image's pre-installed toolchain instead; `npm ci` / `uv pip
|
the load; cold `npm ci` / `uv pip install` cost is a non-blocker.
|
||||||
install` cold cost is a non-blocker.
|
- **Artifacts: stock `actions/upload-artifact@v7`, never `@v3`.** Stock works on this
|
||||||
- Build gates on `typecheck` + `lint` only. The `test` job runs in parallel for
|
forge because gitea/runner 3.x edits the action's client-side `isGhes()` refusal out
|
||||||
visibility but does not block the dev image push. DB-backed / integration tests
|
of its bundle (verified for upload-artifact v4–v7 and download-artifact v4–v8). `@v3`
|
||||||
run against the dev image manually — Inkwell's unit tests are DB-free (no
|
reports success, but Gitea serves artifacts back only through the v4 API
|
||||||
Postgres service lane in CI yet).
|
(`content_encoding = application/zip`), so a v3 upload is stored and invisible to
|
||||||
- `dev` push -> `:dev` + `:<sha>`; `v*` tag -> `:latest` + `:<version>` + `:<sha>`
|
every retrieval path. Upload steps set `if-no-files-found: error` and carry no
|
||||||
(family rule 46).
|
`continue-on-error`, so a failed or empty upload fails the run.
|
||||||
- The production runtime `Dockerfile` tracks python:3.12 so test results stay
|
|
||||||
representative of the deployed image.
|
|
||||||
- **Artifacts — stock `actions/upload-artifact@v7`, never `@v3`.**
|
|
||||||
```yaml
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
```
|
|
||||||
Stock works on this forge since the runner moved to gitea/runner 3.x, which
|
|
||||||
edits the action's client-side `isGhes()` refusal out of its bundle. Proven on
|
|
||||||
2026-09-10 for upload-artifact v4–v7 and download-artifact v4–v8 (Scribe spike
|
|
||||||
#3843). Until then this repo pinned a SHA mirror of the Forgejo project's
|
|
||||||
fork, because upstream threw on the hostname before it opened a connection.
|
|
||||||
|
|
||||||
`@v3` is still broken: it reports success, and Gitea serves artifacts back only
|
Download: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` for the
|
||||||
through the v4 API (`content_encoding = application/zip`), so a v3 upload is
|
id (the global run id, not the repo-scoped run number), then
|
||||||
stored but invisible to every retrieval path. A green job producing nothing
|
`…/actions/artifacts/{id}/zip`. Without `unzip`, use `python3 -m zipfile -e`.
|
||||||
retrievable.
|
- **`libssl3` is covered transitively, on purpose — don't "fix" it.**
|
||||||
|
`dpkg-shlibdeps` lists `libssl3` among the binary's needs, but the `.deb` declares
|
||||||
Both desktop upload steps also set `if-no-files-found: error` and carry **no**
|
only `libwebkit2gtk-4.1-0` + `libgtk-3-0`. `verify.sh` passes it because webkit's
|
||||||
`continue-on-error`. They previously had both defaults inverted, which is how
|
own dependency closure includes OpenSSL, so apt installs it either way. Declaring it
|
||||||
110 unreachable artifacts accumulated on this repo without anyone noticing —
|
would be *worse*: the name is release-dependent (`libssl3` on bookworm,
|
||||||
the upload could fail or match nothing and the run still went green. Scribe
|
`libssl3t64` after the 64-bit time_t transition in trixie/Ubuntu 24.04). If webkit
|
||||||
issues 2255 / 2270 have the full teardown.
|
ever stops pulling OpenSSL, `verify.sh` fails the build.
|
||||||
|
- **The Windows TLS backend is chosen for that lane's sake.** `native-tls` on
|
||||||
Download: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`
|
`x86_64-pc-windows-msvc` resolves to `schannel`, pure-Rust bindings to the OS TLS
|
||||||
for the id (global run id, not the repo-scoped run number), then
|
stack, which keeps C and assembly out of the cross-compile. `rustls` would pull in
|
||||||
`…/actions/artifacts/{id}/zip`. Note the workstation has no `unzip` — use
|
`ring`/`aws-lc-rs` and their assembler, the class of dependency that breaks this
|
||||||
`python3 -m zipfile -e`.
|
lane (`libsqlite3-sys` → `llvm-lib`). Treat a TLS-backend change as a change to the
|
||||||
|
Windows lane.
|
||||||
|
- **What CI cannot verify.** The runner is Debian, so the pacman package is not
|
||||||
|
`pacman -U`-tested; the step logs `.PKGINFO` and the file listing instead. A Linux
|
||||||
|
runner cannot launch a Windows binary or an APK, so green means *built*: a real
|
||||||
|
Windows machine and a real phone are the check before trusting a release. Windows
|
||||||
|
installers are unsigned and trip SmartScreen.
|
||||||
|
|
||||||
## The integration lane
|
## The integration lane
|
||||||
|
|
||||||
Added 2026-08-23. Before it, `alembic upgrade head` ran for the first time when the
|
Real Postgres, so the schema the migrations build is checked against the models that
|
||||||
operator's container started — 26 revisions, none of them ever executed by CI — and
|
read it before any deployment runs `alembic upgrade head`.
|
||||||
the schema the migrations build had never been checked against the models that read
|
|
||||||
it. M13 dropped three columns and rebuilt a STORED GENERATED column with nothing
|
|
||||||
watching.
|
|
||||||
|
|
||||||
Copied from FabledScribe's `integration` job, which had already solved the awkward
|
- **Job key `integration`, no `name:`.** act_runner derives the service container's
|
||||||
parts. Three of them are family rules for a reason:
|
name from the truncated job DISPLAY name, and the discovery step filters `docker ps`
|
||||||
|
by it. A spaced or underscored name breaks the filter.
|
||||||
- **Job key `integration`, no `name:`** (rule 80). act_runner derives the service
|
- **Service hostnames are not routable** on this runner, so the step resolves the
|
||||||
container's name from the truncated job DISPLAY name, and the discovery step filters
|
Postgres container's bridge IP and builds `INKWELL_DATABASE_URL` from it.
|
||||||
`docker ps` by it. A spaced or underscored name breaks the filter.
|
`postgres:5432` will not connect. The runner's docker daemon is shared, so the
|
||||||
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves
|
lookup is scoped to this task's own containers.
|
||||||
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and
|
- **`run:` is busybox sh** — no `/dev/tcp` — so the readiness wait is a small Python
|
||||||
builds `INKWELL_DATABASE_URL` from it. `postgres:5432` will not connect.
|
heredoc. Its terminator must dedent to column 0 after YAML strips the block indent;
|
||||||
- **`run:` is busybox sh** (rule 81) — no `/dev/tcp` — so the readiness wait is a small
|
check with `yaml.safe_load` and print the `run` string if you edit it.
|
||||||
Python heredoc. Its terminator must dedent to column 0 after YAML strips the block
|
- **Real migrations, never `metadata.create_all`.** The `alembic upgrade head` step IS
|
||||||
indent; check with `yaml.safe_load` and print the `run` string if you edit it.
|
the migration test: a broken revision fails the job there, before it can fail a
|
||||||
|
container start.
|
||||||
`postgres:16-alpine`, matching the production compose, so the schema is proven against
|
|
||||||
the Postgres it will actually meet. The schema comes from **real migrations, never
|
|
||||||
`metadata.create_all`** (rule 82): testing a schema no deployment has ever seen proves
|
|
||||||
nothing, and that `alembic upgrade head` step IS the migration test — a broken revision
|
|
||||||
fails the job there, before it can fail a container start.
|
|
||||||
|
|
||||||
Tests are marked `integration` (registered in `pyproject.toml`); the unit lane runs
|
Tests are marked `integration` (registered in `pyproject.toml`); the unit lane runs
|
||||||
`-m "not integration"` and stays DB-free. Data resets with `TRUNCATE ... CASCADE`
|
`-m "not integration"` and stays DB-free. Data resets with `TRUNCATE ... CASCADE`
|
||||||
BEFORE each test rather than after, so a failure leaves its rows behind to look at.
|
BEFORE each test rather than after, so a failure leaves its rows behind to look at.
|
||||||
|
|
||||||
Like `test`, it runs for visibility and does **not** gate the build.
|
## Checking lanes before pushing
|
||||||
|
|
||||||
There is no local way to run it — that would mean standing up Postgres on the
|
The analyzers and tests below can run locally against the CI images, with the
|
||||||
workstation, which rule 12 reserves for an explicit request. This lane is verified in
|
workflow's exact arguments, because a local check that differs from CI is worse than
|
||||||
CI.
|
none. They reproduce CI exactly: same image, same lockfile, same units. Nothing else —
|
||||||
|
no bundle builds, no services — is meant to run locally.
|
||||||
|
|
||||||
## Desktop (Tauri) lane — separate workflow
|
### Rust
|
||||||
|
|
||||||
The Tauri desktop client (`desktop/`) builds in its own workflow,
|
|
||||||
`.forgejo/workflows/desktop.yml`, NOT in `ci.yml` — it's a heavy Rust + AppImage
|
|
||||||
build (~20–40 min) that should only run on `desktop/**` changes, not on every
|
|
||||||
backend/frontend push.
|
|
||||||
|
|
||||||
- **Image:** `git.fabledsword.com/bvandeusen/ci-tauri:1.97` (Rust + Node +
|
|
||||||
WebKitGTK 4.1 + Tauri v2 Linux deps + `tauri-cli`). Selected via
|
|
||||||
`container.image`; `runs-on: python-ci` is only a scheduling label.
|
|
||||||
- **Steps:** build the shared frontend (embedded by `generate_context!`) →
|
|
||||||
`cargo tauri icon app-icon.png` (platform icon set from the committed 1024px
|
|
||||||
source) → `cargo clippy --workspace -D warnings` → `cargo test --workspace` →
|
|
||||||
`cargo fmt --all --check` → `cargo tauri build` (produces `.deb` + `.AppImage`)
|
|
||||||
→ de-bundle the AppImage's graphics libs → verify the `.deb` → repackage for
|
|
||||||
pacman.
|
|
||||||
- **The three analyzer steps run from the REPO ROOT with `--workspace`**, not
|
|
||||||
from `desktop/src-tauri`. Scoping them to the desktop package was correct while
|
|
||||||
it was the only Rust here; after the core was extracted it silently stopped
|
|
||||||
being — the core's 89 tests stopped running, and a fourth crate would not be
|
|
||||||
linted at all. The dependency crates still COMPILE either way, which is exactly
|
|
||||||
why the gap is invisible from a green run. If you add a workspace member, check
|
|
||||||
that it appears in the `cargo test` output before believing the lane covers it.
|
|
||||||
- **`APPIMAGE_EXTRACT_AND_RUN=1`** is set: AppImage tooling FUSE-mounts by default
|
|
||||||
and CI containers have no `/dev/fuse`.
|
|
||||||
- **Packaging tools used from the image** (none installed at job time, rule 5):
|
|
||||||
`dpkg-deb` / `dpkg-query` / `apt-cache` and `dpkg-shlibdeps` (from `dpkg-dev`,
|
|
||||||
pulled in by `build-essential`) for `desktop/packaging/deb/verify.sh`; `tar` +
|
|
||||||
a compressor for `desktop/packaging/arch/package-prebuilt.sh`. Both scripts
|
|
||||||
degrade gracefully rather than hard-failing on an absent optional tool:
|
|
||||||
`bsdtar` (`libarchive-tools`) is used for the pacman package's `.MTREE` when
|
|
||||||
present and skipped when not, compression falls back zstd → xz → gzip, and the
|
|
||||||
`.deb` clean-container install test runs only if a docker CLI is available.
|
|
||||||
Run 2872 confirmed all three optional tools are ABSENT today, so the current
|
|
||||||
build takes every fallback: the pacman package ships as `.pkg.tar.xz` with no
|
|
||||||
`.MTREE`, and the `.deb` clean-container install test is skipped. All three are
|
|
||||||
functional outcomes — pacman installs an `.xz` package fine, and only
|
|
||||||
`pacman -Qkk` file verification needs `.MTREE`. Adding `libarchive-tools` +
|
|
||||||
`zstd` + a docker CLI to `ci-tauri` would upgrade these paths; none of them
|
|
||||||
block a green build.
|
|
||||||
- **`libssl-dev` + `pkg-config` are load-bearing** (both already in `ci-tauri`).
|
|
||||||
Since M10.6 the desktop crate depends on `reqwest` with the **`native-tls`**
|
|
||||||
backend, which on Linux compiles against OpenSSL. Do NOT drop either package
|
|
||||||
from `ci-tauri` in a future slim-down — the Rust build fails at `openssl-sys`.
|
|
||||||
(They're part of Tauri's own documented Linux prerequisites, so they should
|
|
||||||
stay regardless.)
|
|
||||||
- **`libssl3` is covered transitively, on purpose — don't "fix" it.** Since
|
|
||||||
M10.6 `dpkg-shlibdeps` lists `libssl3` among the binary's needs, but the
|
|
||||||
`.deb` declares only `libwebkit2gtk-4.1-0` + `libgtk-3-0`. `verify.sh` passes
|
|
||||||
it because webkit's own recursive dependency closure includes OpenSSL, so apt
|
|
||||||
installs it either way. Declaring it explicitly would be *worse*: the package
|
|
||||||
name is release-dependent (`libssl3` on bookworm, `libssl3t64` after the
|
|
||||||
64-bit-time_t transition in trixie/Ubuntu 24.04), so a hardcoded name freezes
|
|
||||||
the package to the build distro. Leaning on webkit's closure adapts. If webkit
|
|
||||||
ever stops pulling OpenSSL, `verify.sh` fails the build loudly — that guard is
|
|
||||||
what makes the indirection safe.
|
|
||||||
- **Not verifiable in CI:** the runner is Debian, so the pacman package cannot be
|
|
||||||
`pacman -U`-tested here. That step logs `.PKGINFO` + the full file listing so
|
|
||||||
the package is auditable from the run log; a real Arch install is the operator's
|
|
||||||
confirm.
|
|
||||||
|
|
||||||
### Windows lane — second job, second image
|
|
||||||
|
|
||||||
`desktop.yml` also runs a `windows` job that cross-compiles the NSIS installer.
|
|
||||||
|
|
||||||
- **Image:** `git.fabledsword.com/bvandeusen/ci-tauri-win:1.97` (Rust + Node +
|
|
||||||
`cargo-xwin` + LLVM/`lld` + NSIS). A separate image from `ci-tauri` per
|
|
||||||
CI-Runner's `docs/process.md` fork rule — the MSVC CRT/SDK cache alone is >1 GB.
|
|
||||||
Its pins are held in lockstep with `ci-tauri`; bump them together, since both
|
|
||||||
lanes compile the same source.
|
|
||||||
- **Why cross-compile:** there is no Windows build host, and a Windows container
|
|
||||||
cannot run on a Linux host (containers share the host kernel). `cargo-xwin`,
|
|
||||||
`lld-link` and `makensis` are Linux programs that emit Windows PE output.
|
|
||||||
- **NSIS only.** `.msi` requires WiX v3, a Windows program — per Tauri, "`.msi`
|
|
||||||
installers can only be created on Windows."
|
|
||||||
- **Separate job on purpose:** a Windows failure must not block the Linux
|
|
||||||
artifacts, which are the primary product today.
|
|
||||||
- **Weakest verification of any lane.** Tauri documents this path as "not as
|
|
||||||
straight forward as compiling on Windows directly and is not tested as much",
|
|
||||||
to be used "only as a last resort" — and a Linux runner cannot execute a
|
|
||||||
Windows binary. Green means it *built*. A real Windows machine check is
|
|
||||||
mandatory before trusting a release.
|
|
||||||
- **Unsigned.** Installers will trip SmartScreen until a code-signing
|
|
||||||
certificate exists; that is a purchasing decision, not a CI one.
|
|
||||||
- **TLS backend is chosen for this lane's sake.** The desktop crate pins
|
|
||||||
`reqwest` to `native-tls`, which on `x86_64-pc-windows-msvc` resolves to
|
|
||||||
`schannel` — pure-Rust bindings to the OS TLS stack. That keeps C/assembly out
|
|
||||||
of the cross-compile entirely. Switching to `rustls` would pull in
|
|
||||||
`ring`/`aws-lc-rs` and their assembler, which is exactly the class of
|
|
||||||
dependency that broke this lane before (`libsqlite3-sys` → `llvm-lib`). Treat
|
|
||||||
a TLS-backend change as a change to *this lane*, not just a dependency bump.
|
|
||||||
- No Postgres lane (unchanged): the desktop app's local store + sync behavior is
|
|
||||||
verified on the operator's machine, not in CI.
|
|
||||||
|
|
||||||
## Android lane — being rebuilt (M12)
|
|
||||||
|
|
||||||
The Tauri-mobile Android lane is gone. Android is a native Kotlin/Compose client
|
|
||||||
over the shared `inkwell-core` crate instead — see Scribe note 2730 for the
|
|
||||||
decision and milestone M12 for the arc.
|
|
||||||
|
|
||||||
The image it will run on already exists: **`ci-rust-android:1.97`**, repurposed
|
|
||||||
from `ci-tauri-android` rather than deleted (CI-runner `dc802f2`, Scribe #2732).
|
|
||||||
`tauri-cli` is out and `cargo-ndk` is in; the NDK binutils symlinks and the PATH
|
|
||||||
append stayed, because they were never Tauri problems — NDK r23 removed the
|
|
||||||
triple-prefixed binutils that autotools, and so vendored OpenSSL, invokes by bare
|
|
||||||
name. It also carries `ktlint` + `detekt` so the Kotlin analyzer lane needs no
|
|
||||||
second image, and JDK 25 (which requires **Gradle 9.1+** in this repo's wrapper —
|
|
||||||
the old JDK 17 pin existed only because Tauri generated a Gradle 8.x project).
|
|
||||||
|
|
||||||
The Rust pin is in LOCKSTEP with `ci-tauri` and `ci-tauri-win`. All three build
|
|
||||||
`inkwell-core` from one workspace `Cargo.lock` under `--locked`, so a
|
|
||||||
mismatched Rust minor across the lanes would mean divergent resolution for no
|
|
||||||
reason. Bump the three together or not at all.
|
|
||||||
|
|
||||||
## Checking the Kotlin lane before pushing
|
|
||||||
|
|
||||||
Same authorisation and same reasoning as the Rust section below — analyzers, run
|
|
||||||
in the CI image, with the workflow's exact arguments. From `android/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
IMG=git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
|
||||||
DOCK="docker run --rm --user $(id -u):$(id -g) -e HOME=/tmp -v $PWD:/w -w /w"
|
|
||||||
|
|
||||||
$DOCK $IMG ktlint "app/src/main/**/*.kt"
|
|
||||||
$DOCK $IMG detekt --build-upon-default-config --config config/detekt.yml \
|
|
||||||
--input app/src/main/java
|
|
||||||
```
|
|
||||||
|
|
||||||
`HOME=/tmp` because both tools want a writable home for their caches and
|
|
||||||
`--user` has taken the image's away.
|
|
||||||
|
|
||||||
**Neither of these can see a missing import.** They parse Kotlin without
|
|
||||||
resolving symbols, so a file that cannot possibly compile passes both. That is
|
|
||||||
not a gap to work around — it is what these tools are — but it means a clean
|
|
||||||
local run says nothing about whether the code builds. It cost a red CI run on
|
|
||||||
`750d11d`, where `android.os.Build` was lost in a file split and both analyzers
|
|
||||||
were happy.
|
|
||||||
|
|
||||||
So there are two more local checks, each covering one blind spot:
|
|
||||||
|
|
||||||
```
|
|
||||||
python3 android/tools/check-symbols.py
|
|
||||||
python3 android/tools/check-strings.py
|
|
||||||
```
|
|
||||||
|
|
||||||
`check-symbols.py` flags any capitalised identifier that is neither imported,
|
|
||||||
declared in the same package, a type parameter, nor implicitly available — and
|
|
||||||
members of this package's own `object` declarations, so that `Foo.bar()` fails
|
|
||||||
here when `Foo` has no `bar`. That second case exists because moving a function
|
|
||||||
between two objects and forgetting to paste it into the second cost a red run
|
|
||||||
(785ebdb): the call site was correctly qualified and every other gate passed.
|
|
||||||
Not a type checker — `compileDebugKotlin` in CI remains the only real one, and it is
|
|
||||||
also the ONLY lane that type-checks at all, since there is no Android SDK on the
|
|
||||||
workstation.
|
|
||||||
|
|
||||||
`check-strings.py` covers resources, where the compiler is no help either: `R`
|
|
||||||
is generated, so `R.string.whatever` type-checks whether or not the string
|
|
||||||
exists. It catches a missing name, `stringResource` used on a plural or the
|
|
||||||
reverse, and a format string that takes more arguments than the call passes —
|
|
||||||
the last of which renders `%2$s` as literal text rather than failing.
|
|
||||||
|
|
||||||
Run all four before a push that touches Kotlin.
|
|
||||||
|
|
||||||
A caution worth keeping, because it bit twice: a checker of this shape is itself
|
|
||||||
easy to get vacuously right. The first version stripped line comments with
|
|
||||||
`re.sub(r'//.*', src, flags=re.S)`, and DOTALL makes `//.*` swallow each file
|
|
||||||
from its first comment to EOF — so it reported everything clean by examining
|
|
||||||
almost nothing. **Test a checker against a known-bad tree before trusting a
|
|
||||||
green from it**. `check-symbols.py` is verified by deleting the `Build` import
|
|
||||||
from a copy of the source; `check-strings.py` by introducing one of each of its
|
|
||||||
three fault kinds. Its own first version counted Kotlin's trailing commas as
|
|
||||||
arguments and reported three correct call sites as broken — the opposite failure,
|
|
||||||
and the one that teaches you to ignore the tool.
|
|
||||||
|
|
||||||
## A fourth Kotlin check: read the artifact, don't recall the API
|
|
||||||
|
|
||||||
Compose comes from a BOM (`compose-bom` in `libs.versions.toml`), so no file in
|
|
||||||
this repo states which `material3` a build actually gets. Guessing its API and
|
|
||||||
finding out from CI costs eight minutes a try. Resolve and read it instead:
|
|
||||||
|
|
||||||
```
|
|
||||||
# androidx is on Google's Maven, NOT Maven Central — repo1 returns 404
|
|
||||||
BOM=https://dl.google.com/dl/android/maven2/androidx/compose/compose-bom
|
|
||||||
curl -sS $BOM/2026.05.01/compose-bom-2026.05.01.pom | grep -A3 'material3</artifactId>'
|
|
||||||
|
|
||||||
M3=https://dl.google.com/dl/android/maven2/androidx/compose/material3/material3-android
|
|
||||||
curl -sS -o m3-src.jar $M3/1.4.0/material3-android-1.4.0-sources.jar
|
|
||||||
```
|
|
||||||
|
|
||||||
The sources jar answers what javap cannot: default arguments, parameter names,
|
|
||||||
and whether a declaration carries `@ExperimentalMaterial3Api`. That last one is
|
|
||||||
not optional trivia — an unnecessary `@OptIn` is itself a Kotlin warning, so
|
|
||||||
guessing "safely" breaks the build's zero-warning record just as surely as
|
|
||||||
omitting a required one breaks the build.
|
|
||||||
|
|
||||||
Same technique for any dependency. It is how `work-runtime-ktx` was found to be
|
|
||||||
an empty 6 KB stub as of 2.11, with `CoroutineWorker` and
|
|
||||||
`PeriodicWorkRequestBuilder` moved into `work-runtime` itself.
|
|
||||||
|
|
||||||
## Checking the Rust lane before pushing
|
|
||||||
|
|
||||||
There is no Rust toolchain on the workstation (rule 10) and the desktop lane is
|
|
||||||
verified entirely in CI — but the CI image is pullable, so the three analyzer
|
|
||||||
steps can be run against it locally first. **The operator authorised this on
|
|
||||||
2026-08-18** for `fmt`, `clippy` and `test`; it is not licence to run the bundle
|
|
||||||
build or stand up anything.
|
|
||||||
|
|
||||||
Run all three, in this order, before any push that touches Rust:
|
Run all three, in this order, before any push that touches Rust:
|
||||||
|
|
||||||
@@ -326,70 +182,109 @@ $DOCK $IMG cargo clippy --locked --workspace --all-targets -- -D warnings
|
|||||||
$DOCK $IMG cargo test --locked --workspace
|
$DOCK $IMG cargo test --locked --workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
Drop `--check` from the first to apply it. `--user` keeps the container from
|
Drop `--check` from the first to apply it. `--user` keeps the container from leaving
|
||||||
leaving root-owned files behind; `CARGO_HOME` points somewhere writable for that
|
root-owned files behind; `CARGO_HOME` points somewhere writable for that user.
|
||||||
user. Commands are IDENTICAL to the workflow's, deliberately — a local check that
|
`target/` persists between runs, so after the first cold build these take seconds; it
|
||||||
differs from CI is worse than none.
|
is gitignored and reaches ~1.4 GB.
|
||||||
|
|
||||||
**This reproduces CI exactly, not approximately.** On the 2026-08-18 run the
|
Run them on every Rust-touching push, not just the risky-looking ones. A removal looks
|
||||||
local test binary hashes (`thoughtsync_core-bbaae79723888ad1`,
|
safe and often isn't — a private `fn` deleted along with the `pub fn` above it, an
|
||||||
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`,
|
orphaned `#[serde]` attribute — and nothing in the Python or TypeScript lanes compiles
|
||||||
named for the crates as they were before the rename to Inkwell) matched CI run
|
Rust, so a break can travel several commits before a lane that does gets to it.
|
||||||
3931's byte for byte. Same image, same lockfile, same units.
|
|
||||||
|
|
||||||
`target/` persists on the host between runs, so after the first cold build these
|
**Don't infer formatting from existing code.** Some lines in `local/store.rs` exceed
|
||||||
take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it
|
100 characters and survive only because rustfmt cannot break a string literal.
|
||||||
whenever the space is wanted.
|
|
||||||
|
|
||||||
**Run these on every Rust-touching push, not just the ones that feel risky.** Four
|
### Kotlin
|
||||||
consecutive failures across M13's removals — a private `fn` deleted along with the
|
|
||||||
`pub fn` above it, an orphaned `#[serde]` attribute left where a field was removed,
|
|
||||||
and a test pinning a protocol version literal — were all caught by these three
|
|
||||||
commands in under a minute each, after CI had already found them the slow way. A
|
|
||||||
removal is exactly the kind of change that looks safe and isn't: nothing in the
|
|
||||||
Python or TypeScript lanes compiles Rust, so a break can travel several commits
|
|
||||||
before the first lane that does gets to it.
|
|
||||||
|
|
||||||
**Don't infer formatting from existing code.** Several lines in `local/store.rs`
|
From `android/`:
|
||||||
exceed 100 characters and survive only because rustfmt cannot break a string
|
|
||||||
literal — copying that shape caused one of four consecutive fmt-only CI failures,
|
|
||||||
which is what this whole section exists to prevent.
|
|
||||||
|
|
||||||
## Checking the frontend lane before pushing
|
```
|
||||||
|
IMG=git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
||||||
|
DOCK="docker run --rm --user $(id -u):$(id -g) -e HOME=/tmp -v $PWD:/w -w /w"
|
||||||
|
|
||||||
Same technique, same authorisation, same reason — and it covers a gap the Rust gate
|
$DOCK $IMG ktlint "app/src/main/**/*.kt"
|
||||||
cannot: `vue-tsc --noEmit` type-checks only the SCRIPT block, so a malformed TEMPLATE
|
$DOCK $IMG detekt --build-upon-default-config --config config/detekt.yml \
|
||||||
passes the typecheck lane and fails `vite build` in a different workflow. `npm run
|
--input app/src/main/java
|
||||||
build` runs both, which is exactly what the desktop lanes run.
|
```
|
||||||
|
|
||||||
|
`HOME=/tmp` because both tools want a writable home for their caches and `--user` has
|
||||||
|
taken the image's away.
|
||||||
|
|
||||||
|
**Neither can see a missing import.** They parse Kotlin without resolving symbols, so
|
||||||
|
a file that cannot compile passes both. Two more checks cover the blind spots:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 android/tools/check-symbols.py
|
||||||
|
python3 android/tools/check-strings.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`check-symbols.py` flags any capitalised identifier that is neither imported, declared
|
||||||
|
in the same package, a type parameter, nor implicitly available — and members of this
|
||||||
|
package's own `object` declarations, so `Foo.bar()` fails when `Foo` has no `bar`. It
|
||||||
|
is not a type checker: `compileDebugKotlin` in CI is the only one.
|
||||||
|
|
||||||
|
`check-strings.py` covers resources, where the compiler is no help: `R` is generated,
|
||||||
|
so `R.string.whatever` type-checks whether or not the string exists. It catches a
|
||||||
|
missing name, `stringResource` used on a plural or the reverse, and a format string
|
||||||
|
that takes more arguments than the call passes — which renders `%2$s` as literal text
|
||||||
|
rather than failing.
|
||||||
|
|
||||||
|
Run all four before a push that touches Kotlin.
|
||||||
|
|
||||||
|
A checker of this shape is easy to get vacuously right: `re.sub(r'//.*', ...,
|
||||||
|
flags=re.S)` makes `//.*` swallow each file from its first comment to EOF and report
|
||||||
|
everything clean. **Test a checker against a known-bad tree before trusting a green
|
||||||
|
from it.** `check-symbols.py` is verified by deleting an import from a copy of the
|
||||||
|
source; `check-strings.py` by introducing one of each of its three fault kinds.
|
||||||
|
|
||||||
|
**Read the artifact, don't recall the API.** Compose comes from a BOM (`compose-bom`
|
||||||
|
in `libs.versions.toml`), so no file here states which `material3` a build gets, and
|
||||||
|
guessing costs eight minutes of CI a try. Resolve and read it:
|
||||||
|
|
||||||
|
```
|
||||||
|
# androidx is on Google's Maven, NOT Maven Central — repo1 returns 404
|
||||||
|
BOM=https://dl.google.com/dl/android/maven2/androidx/compose/compose-bom
|
||||||
|
curl -sS $BOM/2026.05.01/compose-bom-2026.05.01.pom | grep -A3 'material3</artifactId>'
|
||||||
|
|
||||||
|
M3=https://dl.google.com/dl/android/maven2/androidx/compose/material3/material3-android
|
||||||
|
curl -sS -o m3-src.jar $M3/1.4.0/material3-android-1.4.0-sources.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
The sources jar answers what javap cannot: default arguments, parameter names, and
|
||||||
|
whether a declaration carries `@ExperimentalMaterial3Api`. An unnecessary `@OptIn` is
|
||||||
|
itself a Kotlin warning, so guessing "safely" breaks the zero-warning build as surely
|
||||||
|
as omitting a required one. The same works for any dependency — it is how
|
||||||
|
`work-runtime-ktx` shows up as an empty 6 KB stub as of 2.11, with `CoroutineWorker`
|
||||||
|
and `PeriodicWorkRequestBuilder` in `work-runtime` itself.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
`vue-tsc --noEmit` type-checks only the SCRIPT block, so a malformed TEMPLATE passes
|
||||||
|
the typecheck lane and fails `vite build` in the desktop lanes. `npm run build` runs
|
||||||
|
both:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/w" -w /w/frontend \
|
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/w" -w /w/frontend \
|
||||||
git.fabledsword.com/bvandeusen/ci-python:3.14 sh -c "npm ci --silent && npm run build"
|
git.fabledsword.com/bvandeusen/ci-python:3.14 sh -c "npm ci --silent && npm run build"
|
||||||
```
|
```
|
||||||
|
|
||||||
The typecheck lane uses the `ci-python` image too — it is the node the frontend jobs
|
Delete `frontend/node_modules` and `frontend/dist` afterwards.
|
||||||
already run on, not a separate one. Delete `frontend/node_modules` and `frontend/dist`
|
|
||||||
afterwards; both are gitignored, but neither belongs in a working tree that never
|
|
||||||
builds locally otherwise.
|
|
||||||
|
|
||||||
## The desktop lockfile
|
## The Cargo lockfile
|
||||||
|
|
||||||
`Cargo.lock` is **committed** at the workspace root, per Cargo's own guidance for
|
`Cargo.lock` is **committed** at the workspace root, per Cargo's guidance for binary
|
||||||
binary crates. Without it every CI run re-resolved the graph, which meant a
|
crates, so a build is reproducible from its commit and can't break with no repo
|
||||||
released `.deb`/`.AppImage`/`.exe` couldn't be rebuilt from its tag, a build
|
change, and Renovate has something to bump.
|
||||||
could break with no repo change, and Renovate had nothing to bump (issue 2102).
|
|
||||||
|
|
||||||
Enforced by `--locked` on each job's **first** cargo invocation — `cargo clippy
|
`--locked` on each job's **first** cargo invocation enforces it — `cargo clippy
|
||||||
--locked` on Linux, a dedicated `cargo fetch --locked --target
|
--locked` on Linux, a dedicated `cargo fetch --locked --target x86_64-pc-windows-msvc`
|
||||||
x86_64-pc-windows-msvc` step on Windows. If the manifest and the lockfile
|
step on Windows. If the manifest and the lockfile disagree, the run fails there;
|
||||||
disagree, the run fails there instead of silently re-resolving; everything after
|
everything after it in the same job compiles the recorded versions. The Windows step
|
||||||
it in the same job then compiles the recorded versions, so the flag isn't
|
exists because that job's only other crate-graph command is the cross-compile, and
|
||||||
repeated on the bundle build. The Windows step exists separately because that
|
drift is cheaper to learn in thirty seconds than thirty minutes in.
|
||||||
job's only crate-graph command is the cross-compile itself, and drift is cheaper
|
|
||||||
to learn in the first thirty seconds than thirty minutes in.
|
|
||||||
|
|
||||||
To regenerate it after a dependency change — same reasoning as `cargo fmt`
|
To regenerate it after a dependency change:
|
||||||
above, and resolution is neither a test run nor a build:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/tmp/cargo \
|
docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/tmp/cargo \
|
||||||
@@ -397,59 +292,48 @@ docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/tmp/cargo \
|
|||||||
git.fabledsword.com/bvandeusen/ci-tauri:1.97 cargo fetch
|
git.fabledsword.com/bvandeusen/ci-tauri:1.97 cargo fetch
|
||||||
```
|
```
|
||||||
|
|
||||||
**`cargo fetch`, not `cargo generate-lockfile`.** Both update the lockfile, but
|
**`cargo fetch`, not `cargo generate-lockfile`.** generate-lockfile re-resolves the
|
||||||
generate-lockfile re-resolves the whole graph from scratch and will happily bump
|
whole graph and will bump crates unrelated to your change, turning a two-line manifest
|
||||||
crates that have nothing to do with your change — turning a two-line manifest
|
edit into a few-hundred-line lockfile diff. `cargo fetch` keeps existing pins and adds
|
||||||
edit into a few-hundred-line lockfile diff nobody can review. `cargo fetch`
|
only new entries. Check it stayed additive (`git diff Cargo.lock | grep '^-'` should
|
||||||
performs the minimal resolution: existing pins are preserved, only the new
|
show nothing but re-ordered dependency lists), and commit it with the `Cargo.toml`
|
||||||
entries are added. Verify it stayed additive before committing (`git diff
|
edit — a manifest change pushed without it fails the gate.
|
||||||
Cargo.lock | grep '^-'` should show nothing but re-ordered dependency lists).
|
|
||||||
|
|
||||||
Resolving inside the CI image rather than against some other cargo is what keeps
|
|
||||||
the lockfile format and the picked versions identical to what CI would have
|
|
||||||
chosen. Commit the result in the same change as the `Cargo.toml` edit — a
|
|
||||||
manifest change pushed without it fails the gate.
|
|
||||||
|
|
||||||
## Channel releases: `dev-rolling` and `stable`
|
## Channel releases: `dev-rolling` and `stable`
|
||||||
|
|
||||||
The two update channels are releases on fixed tags, because Fabled-Git has no
|
The two update channels are releases on fixed tags, because Fabled-Git has no
|
||||||
`/releases/latest/download/<asset>` route and the updater needs a permanent URL.
|
`/releases/latest/download/<asset>` route and the updater needs a permanent URL. The
|
||||||
The **channel** is still called `dev` everywhere a person sees it; its **release
|
**channel** is called `dev` everywhere a person sees it; its **release tag** is
|
||||||
tag** is `dev-rolling`. `packaging/channel-tag.sh` is the one mapping CI reads.
|
`dev-rolling`, because a tag named `dev` would shadow the branch and make `git push
|
||||||
`desktop/src-tauri/src/update.rs` and `desktop/packaging/install.sh` carry their
|
origin dev` fail with `error: src refspec dev matches more than one`.
|
||||||
own copy because neither can run it — change all three together.
|
`packaging/channel-tag.sh` is the one mapping CI reads;
|
||||||
|
`desktop/src-tauri/src/update.rs` and `desktop/packaging/install.sh` carry their own
|
||||||
|
copy because neither can run it — change all three together. `tests/test_channel_tag.py`
|
||||||
|
and the `update.rs` tests fail if a channel tag is spelled like a branch.
|
||||||
|
|
||||||
The tag used to be `dev`, which shadowed the branch of the same name: once a clone
|
A desktop app installed before 2026-09-10 reads a `dev` tag that no longer exists and
|
||||||
had fetched it, `git push origin dev` failed with
|
gets no further updates; reinstall it with `install.sh`.
|
||||||
`error: src refspec dev matches more than one` (Scribe #2184). Never name a channel
|
|
||||||
tag after a branch; `tests/test_channel_tag.py` and the `update.rs` tests fail if
|
|
||||||
one is.
|
|
||||||
|
|
||||||
The old `dev` release and tag were deleted on 2026-10-08 (Scribe #3884), with the
|
|
||||||
bridge that kept writing to them. A desktop app installed before 2026-09-10 reads
|
|
||||||
that tag and gets no further updates; reinstall it with `install.sh`.
|
|
||||||
|
|
||||||
## Shell scripts have no CI lane
|
## Shell scripts have no CI lane
|
||||||
|
|
||||||
Nothing lints `desktop/packaging/*.sh`, and a broken installer or publish script
|
Nothing lints `desktop/packaging/*.sh`, and a broken installer or publish script fails
|
||||||
fails at the moment a user runs it, not in a build. Check them before pushing —
|
when a user runs it, not in a build. Check them before pushing — `install.sh` is POSIX
|
||||||
`install.sh` is POSIX sh, the rest are bash:
|
sh, the rest are bash:
|
||||||
|
|
||||||
```
|
```
|
||||||
dash -n desktop/packaging/install.sh # or: sh -n
|
dash -n desktop/packaging/install.sh # or: sh -n
|
||||||
bash -n desktop/packaging/publish-release.sh
|
bash -n desktop/packaging/publish-release.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Where a script resolves URLs from the Fabled-Git API, exercise the resolution
|
Where a script resolves URLs from the Fabled-Git API, exercise the resolution against
|
||||||
against the live instance (plain `curl` reads, no install) rather than trusting
|
the live instance (plain reads, no install) rather than trusting the regex by eye.
|
||||||
the regex by eye. Both channel paths in `install.sh` were verified that way.
|
|
||||||
|
|
||||||
**Hand-assembled JSON: parse it before you push it.** `publish-release.sh` builds
|
**Hand-assembled JSON: parse it before you push it.** `publish-release.sh` builds its
|
||||||
its request bodies as shell strings, and quoting context decides what survives
|
request bodies as shell strings, and quoting context decides what survives into the
|
||||||
into the JSON — a `` \ `` inside an unquoted heredoc loses its backslash to the
|
JSON — a `` \ `` inside an unquoted heredoc loses its backslash to the shell, the same
|
||||||
shell, the same `` \ `` inside a single-quoted variable does not, and reaches
|
`` \ `` inside a single-quoted variable does not, and reaches Fabled-Git as an illegal
|
||||||
Fabled-Git as an illegal escape (HTTP 422, one wasted build). `sh -n` cannot see
|
escape (HTTP 422). `sh -n` cannot see this. Extract the body block and parse it for
|
||||||
this. Extract the body block and parse it for every branch it can take:
|
every branch it can take:
|
||||||
|
|
||||||
```
|
```
|
||||||
sed -n '/^# The install command printed/,/^JSON$/p' desktop/packaging/publish-release.sh > /tmp/body.sh
|
sed -n '/^# The install command printed/,/^JSON$/p' desktop/packaging/publish-release.sh > /tmp/body.sh
|
||||||
|
|||||||
+9
-9
@@ -3,21 +3,22 @@ name = "inkwell-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Inkwell client core — local-first SQLite store and opt-in sync engine"
|
description = "Inkwell client core — local-first SQLite store and opt-in sync engine"
|
||||||
authors = ["bvandeusen"]
|
authors = ["bvandeusen"]
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
# Local-first store (M10.4): bundled = compile SQLite in, so there's no system
|
# Local-first store: bundled = compile SQLite in, so there's no system
|
||||||
# libsqlite dependency to vary across the AppImage / native / Windows / Android builds.
|
# libsqlite dependency to vary across the AppImage / native / Windows / Android builds.
|
||||||
# functions = the `fold` SQL function tag names are compared and indexed by (#5385):
|
# functions = the `fold` SQL function tag names are compared and indexed by:
|
||||||
# SQLite's own lower() folds ASCII only, and the server folds every letter.
|
# SQLite's own lower() folds ASCII only, and the server folds every letter.
|
||||||
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
|
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
# HTTP for the opt-in server handshake (M10.6) and the sync engine (M10.7).
|
# HTTP for the opt-in server handshake and the sync engine.
|
||||||
#
|
#
|
||||||
# native-tls, NOT rustls, deliberately: on x86_64-pc-windows-msvc native-tls resolves
|
# native-tls, NOT rustls, deliberately: on x86_64-pc-windows-msvc native-tls resolves
|
||||||
# to `schannel` — pure-Rust bindings to the OS TLS stack — so nothing C or assembly
|
# to `schannel` — pure-Rust bindings to the OS TLS stack — so nothing C or assembly
|
||||||
@@ -43,12 +44,11 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
|
|||||||
# Without this the build dies at `openssl-sys`: "Could not find directory of
|
# Without this the build dies at `openssl-sys`: "Could not find directory of
|
||||||
# OpenSSL installation".
|
# OpenSSL installation".
|
||||||
#
|
#
|
||||||
# `vendored` compiles OpenSSL from source with the NDK toolchain. The alternative
|
# `vendored` compiles OpenSSL from source with the NDK toolchain. Not rustls on
|
||||||
# was rustls on Android only, which builds faster — but rustls ships its own root
|
# Android, though it builds faster: rustls ships its own root store, so the phone
|
||||||
# store, so the phone would trust a DIFFERENT set of certificates than the desktop
|
# would trust a DIFFERENT set of certificates than the desktop does, and a
|
||||||
# does. A self-hosted server behind a private or enterprise CA would then work on
|
# self-hosted server behind a private or enterprise CA would work on one surface
|
||||||
# one surface and fail on another, and "the surfaces behave the same" is worth more
|
# and fail on another.
|
||||||
# than build minutes.
|
|
||||||
#
|
#
|
||||||
# Declared as a direct dependency purely to turn the feature on: cargo's feature
|
# Declared as a direct dependency purely to turn the feature on: cargo's feature
|
||||||
# unification applies it to the copy `native-tls` pulls in transitively.
|
# unification applies it to the copy `native-tls` pulls in transitively.
|
||||||
|
|||||||
+1
-4
@@ -2,10 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
|
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
|
||||||
//! the Android client binds it through uniffi. Neither owns it, and a change to
|
//! the Android client binds it through uniffi. Neither owns it, and a change to
|
||||||
//! either must not require touching this crate — that separation is the whole point
|
//! either must not require touching this crate — that separation is the whole point.
|
||||||
//! (see Scribe note 2730). It was already true before the split: every file here
|
|
||||||
//! carried zero Tauri references, which is what made the extraction a move rather
|
|
||||||
//! than a rewrite.
|
|
||||||
//!
|
//!
|
||||||
//! - `local` — the source of truth. Works with no server and no account.
|
//! - `local` — the source of truth. Works with no server and no account.
|
||||||
//! - `sync` — entirely opt-in. Nothing in it runs until a server is linked.
|
//! - `sync` — entirely opt-in. Nothing in it runs until a server is linked.
|
||||||
|
|||||||
+20
-27
@@ -6,7 +6,7 @@
|
|||||||
//! letters, digits, `_` and `-`. On save these become labels attached with
|
//! letters, digits, `_` and `-`. On save these become labels attached with
|
||||||
//! `via_tag = true`. The server and the web run the same cases
|
//! `via_tag = true`. The server and the web run the same cases
|
||||||
//! (core/testdata/grammar.json).
|
//! (core/testdata/grammar.json).
|
||||||
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
|
//! - `- [ ] item`: a checklist item. The body IS the checklist — there is no
|
||||||
//! table of items beside it, so a list can sit between two paragraphs instead of
|
//! table of items beside it, so a list can sit between two paragraphs instead of
|
||||||
//! only after them.
|
//! only after them.
|
||||||
//!
|
//!
|
||||||
@@ -17,9 +17,6 @@
|
|||||||
//! and the rewriters below are how one is edited.
|
//! and the rewriters below are how one is edited.
|
||||||
//!
|
//!
|
||||||
//! Dedupes case-insensitively, preserving first-seen order.
|
//! Dedupes case-insensitively, preserving first-seen order.
|
||||||
//!
|
|
||||||
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
|
|
||||||
//! capture-and-recall surface, and a linking system is organization.
|
|
||||||
|
|
||||||
/// Every `#tag` in ONE line, as `(start, end, name)` in char indices.
|
/// Every `#tag` in ONE line, as `(start, end, name)` in char indices.
|
||||||
///
|
///
|
||||||
@@ -31,10 +28,9 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
|
|||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < chars.len() {
|
while i < chars.len() {
|
||||||
if chars[i] == '#' {
|
if chars[i] == '#' {
|
||||||
// Start of line or after whitespace, and nothing else. Any non-tag
|
// Start of line or after whitespace, and nothing else, so `(#todo)` and a
|
||||||
// character used to count, which made `(#todo)` a tag here and plain
|
// pasted URL's `/#section` are not tags. All three implementations share
|
||||||
// text on the server, and made the `/#section` of a pasted URL a label.
|
// this rule.
|
||||||
// Whitespace is the rule all three implementations now share (#5166).
|
|
||||||
let boundary = i == 0 || chars[i - 1].is_whitespace();
|
let boundary = i == 0 || chars[i - 1].is_whitespace();
|
||||||
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
|
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
|
||||||
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
|
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
|
||||||
@@ -54,8 +50,8 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
|
|||||||
|
|
||||||
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
|
/// 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
|
/// The card colours a tag where it was typed rather than repeating it as a chip.
|
||||||
/// colours the token where it was typed instead. To do that a renderer needs the
|
/// To do that a renderer needs the
|
||||||
/// SPAN, not just the name, and asking it to find the name again would be a second
|
/// 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.
|
/// grammar quietly disagreeing with this one about what `##a` or `#1` is.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -142,8 +138,8 @@ fn collapse_blank_runs(text: &str) -> String {
|
|||||||
/// else is left exactly as written.
|
/// else is left exactly as written.
|
||||||
///
|
///
|
||||||
/// The MIRROR of `split_body_tags` in the server's `notes/tags.py`, and it has to stay
|
/// 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
|
/// one: a note lifted differently here than there would change the moment it synced.
|
||||||
/// moment it synced. Same discipline, and the same reason, as `DerivedTint`.
|
/// Same discipline, and the same reason, as `DerivedTint`.
|
||||||
///
|
///
|
||||||
/// The conservative reading of "standalone" is deliberate. A trailing tag is
|
/// 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,
|
/// ambiguous and the text does not say which it is — `buy milk #grocery` is filing,
|
||||||
@@ -152,8 +148,8 @@ fn collapse_blank_runs(text: &str) -> String {
|
|||||||
///
|
///
|
||||||
/// `standalone` tags become ORDINARY labels (`via_tag = 0`): nothing is left to derive
|
/// `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
|
/// 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
|
/// one. `inline` tags stay derived. That is what `via_tag` means: backed by text still
|
||||||
/// here on — backed by text still in the body.
|
/// in the body.
|
||||||
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
|
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
|
||||||
let mut standalone: Vec<String> = Vec::new();
|
let mut standalone: Vec<String> = Vec::new();
|
||||||
let mut inline: Vec<String> = Vec::new();
|
let mut inline: Vec<String> = Vec::new();
|
||||||
@@ -220,7 +216,7 @@ fn is_tag_char(c: char) -> bool {
|
|||||||
/// How two tag names compare: equal once every letter is lowercased, accented ones
|
/// How two tag names compare: equal once every letter is lowercased, accented ones
|
||||||
/// included, so "Café" and "CAFÉ" are one tag. The server's `labeling.named` folds
|
/// included, so "Café" and "CAFÉ" are one tag. The server's `labeling.named` folds
|
||||||
/// the same way (Postgres `lower`, Python `str.lower`), and the store's `fold` SQL
|
/// the same way (Postgres `lower`, Python `str.lower`), and the store's `fold` SQL
|
||||||
/// function is this (#5385).
|
/// function is this.
|
||||||
pub(crate) fn fold(name: &str) -> String {
|
pub(crate) fn fold(name: &str) -> String {
|
||||||
name.to_lowercase()
|
name.to_lowercase()
|
||||||
}
|
}
|
||||||
@@ -248,8 +244,7 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
|||||||
// parse it would make a half-typed list stop being a list.
|
// 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
|
/// 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
|
/// its identity; the wire carries text and checked, never an id.
|
||||||
/// wire ever carried (`push.rs` sent text and checked, never an id).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct DerivedItem {
|
pub struct DerivedItem {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
@@ -361,9 +356,8 @@ pub const DISPLAY_TITLE_CAP: usize = 200;
|
|||||||
/// twice, and they have to agree or a synced note is called different things on either
|
/// twice, and they have to agree or a synced note is called different things on either
|
||||||
/// side of the wire. Both run the `display_titles` cases in testdata/grammar.json.
|
/// side of the wire. Both run the `display_titles` cases in testdata/grammar.json.
|
||||||
///
|
///
|
||||||
/// It no longer needs the items, because the items ARE lines of the body now (M304).
|
/// The items ARE lines of the body, so it strips the task marker off: a list-only
|
||||||
/// What it needs instead is to strip the task marker off: a list-only note is still
|
/// note is named by its first item, and calling that note "- [ ] milk" would be showing
|
||||||
/// 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
|
/// 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.
|
/// naming the note "", which is what a half-typed list would otherwise do.
|
||||||
pub fn display_title(body: &str) -> String {
|
pub fn display_title(body: &str) -> String {
|
||||||
@@ -431,9 +425,8 @@ pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
|
|||||||
///
|
///
|
||||||
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
|
/// 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
|
/// 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
|
/// cosmetic: the migrations that fold checklist rows into bodies use the same layout,
|
||||||
/// layout, so an export taken before the migration and one taken after have to
|
/// so every surface lands on byte-identical bodies.
|
||||||
/// agree byte for byte.
|
|
||||||
///
|
///
|
||||||
/// `checked` is a parameter rather than always false because the two migrations that
|
/// `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
|
/// fold existing rows into bodies have to carry the state those rows were in. A new
|
||||||
@@ -513,8 +506,8 @@ mod tests {
|
|||||||
// ── lifting standalone tags ──────────────────────────────────────────────
|
// ── lifting standalone tags ──────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
|
// 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
|
// A note lifted differently here than there would change the moment it synced,
|
||||||
// moment it synced, so these are the cases that file agrees to.
|
// so these are the cases that file agrees to.
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lifts_a_line_that_is_nothing_but_tags() {
|
fn lifts_a_line_that_is_nothing_but_tags() {
|
||||||
@@ -662,8 +655,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn append_spaces_like_the_exporter() {
|
fn append_spaces_like_the_exporter() {
|
||||||
// Prose then a blank line then the list — byte-for-byte what
|
// Prose then a blank line then the list — byte-for-byte what
|
||||||
// import_export.py:_note_markdown writes, which is what the server
|
// import_export.py:_note_markdown writes, which is the layout the server
|
||||||
// migration will fold existing rows into.
|
// migration folds rows into.
|
||||||
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
|
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
|
||||||
// Nothing between consecutive items.
|
// Nothing between consecutive items.
|
||||||
let one = "a note\n\n- [ ] milk";
|
let one = "a note\n\n- [ ] milk";
|
||||||
|
|||||||
@@ -73,9 +73,7 @@ pub fn open(path: &Path) -> rusqlite::Result<Db> {
|
|||||||
/// Open a migrated, in-memory store.
|
/// Open a migrated, in-memory store.
|
||||||
///
|
///
|
||||||
/// Exists so a CONSUMER can test against a real schema without taking a rusqlite
|
/// Exists so a CONSUMER can test against a real schema without taking a rusqlite
|
||||||
/// dependency of its own just to build a `Db` — which is exactly what the desktop
|
/// dependency of its own just to build a `Db`.
|
||||||
/// crate was doing before the core was extracted. The Android bindings will want the
|
|
||||||
/// same thing.
|
|
||||||
pub fn open_in_memory() -> rusqlite::Result<Db> {
|
pub fn open_in_memory() -> rusqlite::Result<Db> {
|
||||||
Ok(Db(Mutex::new(memory_conn()?)))
|
Ok(Db(Mutex::new(memory_conn()?)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ pub struct Note {
|
|||||||
pub previews: Vec<LinkPreview>,
|
pub previews: Vec<LinkPreview>,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
/// How this account holds the note (#5175): `owner`, or `edit`/`view` for one
|
/// How this account holds the note: `owner`, or `edit`/`view` for one
|
||||||
/// someone shared with it. Named and valued as the server's, so the shared
|
/// someone shared with it. Named and valued as the server's, so the shared
|
||||||
/// frontend gates the editor identically either way.
|
/// frontend gates the editor identically either way.
|
||||||
pub permission: String,
|
pub permission: String,
|
||||||
@@ -119,7 +119,7 @@ pub struct Label {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub count: Option<i64>,
|
pub count: Option<i64>,
|
||||||
/// The newest `updated_at` among the untrashed notes carrying it, or None when
|
/// The newest `updated_at` among the untrashed notes carrying it, or None when
|
||||||
/// nothing does. The drawer keeps the few most recent tags in reach (#5931).
|
/// nothing does. The drawer keeps the few most recent tags in reach.
|
||||||
pub last_used: Option<String>,
|
pub last_used: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Trash retention for a device with no server (M11.3).
|
//! Trash retention for a device with no server.
|
||||||
//!
|
//!
|
||||||
//! The server owns this policy whenever there IS one: a linked client learns about
|
//! The server owns this policy whenever there IS one: a linked client learns about
|
||||||
//! every permanent deletion from the delta feed, as a tombstone, and does exactly
|
//! every permanent deletion from the delta feed, as a tombstone, and does exactly
|
||||||
@@ -123,8 +123,7 @@ mod tests {
|
|||||||
fn a_note_just_inside_the_window_survives() {
|
fn a_note_just_inside_the_window_survives() {
|
||||||
// The comparison is STRICTLY older than the cutoff, so a note with a minute
|
// The comparison is STRICTLY older than the cutoff, so a note with a minute
|
||||||
// of its 30 days still to run is kept. An exact tie isn't testable against a
|
// of its 30 days still to run is kept. An exact tie isn't testable against a
|
||||||
// wall clock — the sweep reads `now` microseconds after the row is stamped,
|
// wall clock — the sweep reads `now` microseconds after the row is stamped.
|
||||||
// which is precisely how the first version of this test failed.
|
|
||||||
let conn = db();
|
let conn = db();
|
||||||
let almost = Duration::days(30) - Duration::minutes(1);
|
let almost = Duration::days(30) - Duration::minutes(1);
|
||||||
trashed_note_aged(&conn, "boundary", almost);
|
trashed_note_aged(&conn, "boundary", almost);
|
||||||
|
|||||||
+35
-44
@@ -112,13 +112,11 @@ CREATE TABLE sync_state (
|
|||||||
INSERT INTO sync_state (id) VALUES (1);
|
INSERT INTO sync_state (id) VALUES (1);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v2 (M10.7c): local tombstones.
|
// v2: local tombstones.
|
||||||
//
|
//
|
||||||
// A permanent delete previously just dropped the row, which left NO record that it
|
// A permanent delete is recorded here until the server acknowledges it. Dropping the
|
||||||
// ever existed. Offline, that means the delete can never be pushed — and the next
|
// row alone leaves no record that it existed, so an offline delete could never be
|
||||||
// pull would faithfully resurrect the note from the server. A deletion that undoes
|
// pushed and the next pull would resurrect the note.
|
||||||
// itself is about the worst outcome sync can produce, so deletes are now recorded
|
|
||||||
// here until they've been acknowledged by the server and cleared.
|
|
||||||
const SCHEMA_V2: &str = r#"
|
const SCHEMA_V2: &str = r#"
|
||||||
CREATE TABLE pending_deletes (
|
CREATE TABLE pending_deletes (
|
||||||
entity TEXT NOT NULL, -- 'note' | 'label'
|
entity TEXT NOT NULL, -- 'note' | 'label'
|
||||||
@@ -128,7 +126,7 @@ CREATE TABLE pending_deletes (
|
|||||||
);
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v3 (M10.7e): when the last successful sync finished.
|
// v3: when the last successful sync finished.
|
||||||
//
|
//
|
||||||
// The cursor alone can't answer "is this up to date?" — it's a revision watermark,
|
// The cursor alone can't answer "is this up to date?" — it's a revision watermark,
|
||||||
// not a time, and it doesn't move at all when a sync legitimately finds nothing new.
|
// not a time, and it doesn't move at all when a sync legitimately finds nothing new.
|
||||||
@@ -137,24 +135,21 @@ const SCHEMA_V3: &str = r#"
|
|||||||
ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT;
|
ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v4 (M11.3): WHEN a note was trashed.
|
// v4: WHEN a note was trashed.
|
||||||
//
|
//
|
||||||
// The table only ever recorded THAT a note was trashed, which is enough to draw a
|
// Retention needs an age: without a timestamp there is no telling a note trashed this
|
||||||
// Trash view and nothing else. Retention needs an age: without a timestamp there is
|
// morning from one trashed last spring, so an offline device could never expire its
|
||||||
// no way to tell a note trashed this morning from one trashed last spring, so an
|
// own trash, and the UI couldn't warn anyone before it did.
|
||||||
// offline device could never expire its own trash — and the UI couldn't warn anyone
|
|
||||||
// before it did.
|
|
||||||
// It also records the LINKED server's retention window, captured from /api/config.
|
// It also records the LINKED server's retention window, captured from /api/config.
|
||||||
// Once linked, the server's policy is the one that actually applies, so showing this
|
// Once linked, the server's policy is the one that applies, so showing this device's
|
||||||
// device's offline default would put a countdown on screen that doesn't match what
|
// offline default would put a countdown on screen that doesn't match what happens.
|
||||||
// happens — a wrong deadline is worse than none.
|
|
||||||
const SCHEMA_V4: &str = r#"
|
const SCHEMA_V4: &str = r#"
|
||||||
ALTER TABLE notes ADD COLUMN trashed_at TEXT;
|
ALTER TABLE notes ADD COLUMN trashed_at TEXT;
|
||||||
UPDATE notes SET trashed_at = updated_at WHERE trashed = 1;
|
UPDATE notes SET trashed_at = updated_at WHERE trashed = 1;
|
||||||
ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER;
|
ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v5 (M10.9): small key/value app preferences.
|
// v5: small key/value app preferences.
|
||||||
//
|
//
|
||||||
// The first entry is the update channel, which is neither note data nor part of the
|
// The first entry is the update channel, which is neither note data nor part of the
|
||||||
// server link — so it belongs in neither `notes` nor `sync_state`. Generic on
|
// server link — so it belongs in neither `notes` nor `sync_state`. Generic on
|
||||||
@@ -166,17 +161,16 @@ CREATE TABLE prefs (
|
|||||||
);
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v6 (M13 step 2): `kind` is gone. A checklist is something a note HAS, not something
|
// v6: drops `kind`. A checklist is something a note HAS, not something a note IS —
|
||||||
// a note IS — the column was a mode flag with no enum and no constraint behind it,
|
// the column was a mode flag with no enum and no constraint behind it. A note that
|
||||||
// and `note_items` was never tied to it. Dropping it loses nothing: a note that was
|
// was 'list' keeps every one of its items.
|
||||||
// 'list' keeps every one of its items.
|
|
||||||
//
|
//
|
||||||
// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it.
|
// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it.
|
||||||
const SCHEMA_V6: &str = r#"
|
const SCHEMA_V6: &str = r#"
|
||||||
ALTER TABLE notes DROP COLUMN kind;
|
ALTER TABLE notes DROP COLUMN kind;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v7 (M13 step 3): the title field is gone. A note is a body plus optional items, and
|
// v7: drops the title field. A note is a body plus optional items, and
|
||||||
// its NAME is the first non-empty line of that body, falling back to its first item —
|
// its NAME is the first non-empty line of that body, falling back to its first item —
|
||||||
// derived at read time, never stored (see store::display_title).
|
// derived at read time, never stored (see store::display_title).
|
||||||
//
|
//
|
||||||
@@ -186,7 +180,7 @@ ALTER TABLE notes DROP COLUMN title;
|
|||||||
ALTER TABLE note_revisions DROP COLUMN title;
|
ALTER TABLE note_revisions DROP COLUMN title;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v8 (M304): `checklist_items` is gone. The body IS the checklist — a `- [ ] milk`
|
// v8: folds `checklist_items` into bodies. The body IS the checklist — a `- [ ] milk`
|
||||||
// line is the item — so a list can sit between two paragraphs instead of only after
|
// 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.
|
// them, which a side table could never express no matter how it was styled.
|
||||||
//
|
//
|
||||||
@@ -251,13 +245,12 @@ fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives
|
// v9: drops `notes.color`. A card is one neutral surface and colour lives only on a
|
||||||
// only on a tag, so the column was written by a picker nothing read and read by nothing
|
// tag; `labels.color` is untouched.
|
||||||
// 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
|
// 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
|
// 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
|
// sit there forever, and a view that silently filters on a field the app doesn't have
|
||||||
// is worse than one that visibly lost a criterion. Guarded on `json_valid` because a
|
// 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.
|
// corrupt blob must keep whatever it holds, not become NULL.
|
||||||
//
|
//
|
||||||
@@ -275,12 +268,11 @@ UPDATE saved_filters
|
|||||||
AND params LIKE '%"color"%';
|
AND params LIKE '%"color"%';
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v10 (#5168): an attachment can be created on THIS device.
|
// v10: an attachment can be created on THIS device.
|
||||||
//
|
//
|
||||||
// Until now every attachment row arrived on the delta feed, so every one was already on
|
// A file attached here, offline, is not on the server yet, and `uploaded = 0` is the
|
||||||
// the server. A file attached here, offline, is not, and `uploaded = 0` is the queue
|
// queue push drains once the note has landed. Rows from before this step all came
|
||||||
// push drains once the note has landed. The rows already here all came from the
|
// from the server, which is why the default is 1.
|
||||||
// server, which is why the default is 1.
|
|
||||||
//
|
//
|
||||||
// `upload_error` holds a refusal that retrying won't fix — over the size limit, an id
|
// `upload_error` holds a refusal that retrying won't fix — over the size limit, an id
|
||||||
// already in use, bytes that don't match their hash. A row carrying one leaves the
|
// already in use, bytes that don't match their hash. A row carrying one leaves the
|
||||||
@@ -292,15 +284,15 @@ ALTER TABLE attachments ADD COLUMN uploaded INTEGER NOT NULL DEFAULT 1;
|
|||||||
ALTER TABLE attachments ADD COLUMN upload_error TEXT;
|
ALTER TABLE attachments ADD COLUMN upload_error TEXT;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v11 (#5175): notes other people shared with this account.
|
// v11: notes other people shared with this account.
|
||||||
//
|
//
|
||||||
// The feed now carries them, so a note says how it is held: `permission` is `owner`,
|
// The feed carries them, so a note says how it is held: `permission` is `owner`,
|
||||||
// `edit` (its text may change here) or `view`. Every note already on a device is the
|
// `edit` (its text may change here) or `view`. Every note already on a device is the
|
||||||
// account's own, which is why the default is `owner`. `shared` is whether the owner
|
// account's own, which is why the default is `owner`. `shared` is whether the owner
|
||||||
// has shared it with anyone, and `shared_by_*` names the owner of one shared with us.
|
// has shared it with anyone, and `shared_by_*` names the owner of one shared with us.
|
||||||
//
|
//
|
||||||
// `shares_synced` is whether this device has pulled with shares since it was linked.
|
// `shares_synced` is whether this device has pulled with shares since it was linked.
|
||||||
// The notes shared before this build sit below the cursor it already holds, so the
|
// Notes shared before a device reaches this step sit below the cursor it holds, so the
|
||||||
// first pull from a server offering `shares` starts again from zero (see
|
// first pull from a server offering `shares` starts again from zero (see
|
||||||
// `engine::run_cycle`). A pull applies idempotently, so starting over costs a
|
// `engine::run_cycle`). A pull applies idempotently, so starting over costs a
|
||||||
// download and nothing else.
|
// download and nothing else.
|
||||||
@@ -312,29 +304,29 @@ ALTER TABLE notes ADD COLUMN shared_by_name TEXT;
|
|||||||
ALTER TABLE sync_state ADD COLUMN shares_synced INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE sync_state ADD COLUMN shares_synced INTEGER NOT NULL DEFAULT 0;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v12 (#5176): a shared note's pin, archive and position are this account's own.
|
// v12: a shared note's pin, archive and position are this account's own.
|
||||||
//
|
//
|
||||||
// `state_at` is when they last changed here, on a note someone else owns. It is kept
|
// `state_at` is when they last changed here, on a note someone else owns. It is kept
|
||||||
// apart from `updated_at`, which stays the time of the note's TEXT: pinning a copy
|
// apart from `updated_at`, which stays the time of the note's TEXT: pinning a copy
|
||||||
// whose text is behind the owner's must not make that text look like the newer
|
// whose text is behind the owner's must not make that text look like the newer
|
||||||
// edit when it is pushed. Null on our own notes, where `updated_at` covers both.
|
// edit when it is pushed. Null on our own notes, where `updated_at` covers both.
|
||||||
//
|
//
|
||||||
// `sync_state.shares_synced` now holds a level rather than a flag (see
|
// `sync_state.shares_synced` holds a level rather than a flag (see
|
||||||
// `state::SHARED_STATE`), and a device reaching this level pulls everything once
|
// `state::SHARED_STATE`), and a device reaching this level pulls everything once
|
||||||
// more: the shared notes it holds carry their owner's pins until it does.
|
// more: the shared notes it holds carry their owner's pins until it does.
|
||||||
const SCHEMA_V12: &str = r#"
|
const SCHEMA_V12: &str = r#"
|
||||||
ALTER TABLE notes ADD COLUMN state_at TEXT;
|
ALTER TABLE notes ADD COLUMN state_at TEXT;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v13 (#5180): saved views are gone. They never synced, so this device's set was its
|
// v13: drops saved views. They never synced, so this device's set was its
|
||||||
// own and nothing else holds a copy.
|
// own and nothing else holds a copy.
|
||||||
const SCHEMA_V13: &str = r#"
|
const SCHEMA_V13: &str = r#"
|
||||||
DROP TABLE saved_filters;
|
DROP TABLE saved_filters;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v14 (#5385): tag names compare by `fold`, which lowercases every letter. Until now
|
// v14: tag names compare by `fold`, which lowercases every letter. SQLite's lower()
|
||||||
// the index and the queries used SQLite's lower(), which folds ASCII only, so "Café"
|
// folds ASCII only, so with it "Café" and "CAFÉ" could be two tags on a device while
|
||||||
// and "CAFÉ" could be two tags on a device while the server held one.
|
// the server held one.
|
||||||
//
|
//
|
||||||
// Pairs that already exist are merged before the index can be rebuilt. THE OLDER ROW
|
// Pairs that already exist are merged before the index can be rebuilt. THE OLDER ROW
|
||||||
// SURVIVES, as in `store::rename_label`, so every device holding the same pair picks
|
// SURVIVES, as in `store::rename_label`, so every device holding the same pair picks
|
||||||
@@ -452,7 +444,7 @@ fn register_fold(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A database as it stood before M304 — items still in their own table.
|
/// A database at v7, with items still in their own table.
|
||||||
fn v7_db() -> Connection {
|
fn v7_db() -> Connection {
|
||||||
let conn = Connection::open_in_memory().expect("open");
|
let conn = Connection::open_in_memory().expect("open");
|
||||||
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
|
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
|
||||||
@@ -602,8 +594,7 @@ mod tests {
|
|||||||
.collect::<rusqlite::Result<Vec<String>>>()
|
.collect::<rusqlite::Result<Vec<String>>>()
|
||||||
.expect("collect");
|
.expect("collect");
|
||||||
assert!(!columns.iter().any(|c| c == "color"));
|
assert!(!columns.iter().any(|c| c == "color"));
|
||||||
// The one that survived. Getting this wrong would take every tag's colour with
|
// The one that stays. Getting this wrong would take every tag's colour with it.
|
||||||
// it, which is the whole thing M315 was keeping.
|
|
||||||
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
|
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
|
||||||
let label_columns: Vec<String> = stmt
|
let label_columns: Vec<String> = stmt
|
||||||
.query_map([], |r| r.get::<_, String>(1))
|
.query_map([], |r| r.get::<_, String>(1))
|
||||||
|
|||||||
+28
-37
@@ -48,11 +48,9 @@ fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLab
|
|||||||
|
|
||||||
/// The note's checklist, read out of its body. No query, because there is no table.
|
/// 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
|
/// A `- [ ] milk` line IS the item. The id is the item's ORDINAL rather than a uuid:
|
||||||
/// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and
|
/// sync carries text and checked, never an id, and the ordinal is what the rewriters
|
||||||
/// checked and never an id, and both sides replaced the whole list on every sync. It
|
/// in `derive` take, so a UI holding an id can act on it directly.
|
||||||
/// 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> {
|
fn items_of(body: &str) -> Vec<ChecklistItem> {
|
||||||
derive::extract_items(body)
|
derive::extract_items(body)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -122,10 +120,9 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
|||||||
permission, shared, shared_by_id, shared_by_name
|
permission, shared, shared_by_id, shared_by_name
|
||||||
FROM notes WHERE id = ?1",
|
FROM notes WHERE id = ?1",
|
||||||
[id],
|
[id],
|
||||||
// By NAME, not by position. Dropping `color` shifted every column after it
|
// By NAME, not by position: dropping a column shifts every one after it, and
|
||||||
// and the two timestamps were missed (fa89da1): created_at read updated_at
|
// a positional read would then load the wrong timestamps with nothing
|
||||||
// and updated_at read trashed_at, with nothing failing. A name either
|
// failing. A name either resolves or errors.
|
||||||
// resolves or errors.
|
|
||||||
|r| {
|
|r| {
|
||||||
Ok(Note {
|
Ok(Note {
|
||||||
id: r.get("id")?,
|
id: r.get("id")?,
|
||||||
@@ -166,11 +163,11 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
|||||||
Ok(note)
|
Ok(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- notes shared with this account (#5175) ---------------------------------
|
// ---- notes shared with this account ------------------------------------------
|
||||||
//
|
//
|
||||||
// A note someone else owns arrives with `permission` `edit` (its text may change
|
// A note someone else owns arrives with `permission` `edit` (its text may change
|
||||||
// here) or `view` (it may not). Its pin, archive and place on this board are this
|
// here) or `view` (it may not). Its pin, archive and place on this board are this
|
||||||
// account's own either way (#5176). Everything else about it — trash, reminders,
|
// account's own either way. Everything else about it — trash, reminders,
|
||||||
// labels, files — stays its owner's. The server enforces the same split;
|
// labels, files — stays its owner's. The server enforces the same split;
|
||||||
// refusing here as well tells the person at once, instead of letting an edit be made,
|
// refusing here as well tells the person at once, instead of letting an edit be made,
|
||||||
// queued, and then thrown away by the next sync.
|
// queued, and then thrown away by the next sync.
|
||||||
@@ -268,21 +265,19 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
|
|||||||
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
|
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
|
||||||
/// the shortened body back.
|
/// the shortened body back.
|
||||||
///
|
///
|
||||||
/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it
|
/// NAMED FOR THE MUTATION: it rewrites `notes.body`, and every caller writes the body
|
||||||
/// now rewrites `notes.body`, and every caller writes the body just before calling —
|
/// just before calling, so this overwrites what they wrote, on purpose.
|
||||||
/// so this overwrites what they wrote, on purpose.
|
|
||||||
///
|
///
|
||||||
/// `display_title` needs no attention here, unlike on the server: the core derives it
|
/// `display_title` needs no attention here, unlike on the server: the core derives it
|
||||||
/// on READ (`derive::display_title`, called from `load_note`) rather than storing
|
/// on READ (`derive::display_title`, called from `load_note`) rather than storing
|
||||||
/// it, so there is no persisted copy to go stale.
|
/// 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`
|
/// 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:
|
/// means — backed by text still in the body:
|
||||||
///
|
///
|
||||||
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it any
|
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it, and
|
||||||
/// more, and the way to remove it becomes the chip's ×.
|
/// the way to remove it is the chip's ×.
|
||||||
/// inline left in place, attached via_tag = 1, still detached when its text
|
/// inline left in place, attached via_tag = 1, detached when its text goes.
|
||||||
/// goes. Unchanged from before.
|
|
||||||
///
|
///
|
||||||
/// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`.
|
/// 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<()> {
|
fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
|
||||||
@@ -384,9 +379,8 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
|
|||||||
|
|
||||||
if let Some(f) = &q.facets {
|
if let Some(f) = &q.facets {
|
||||||
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
|
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
|
||||||
// One placeholder, one bind. This pushed the pattern twice after the
|
// One placeholder, one bind: rusqlite refuses a count mismatch, which
|
||||||
// title column went (95aa10c), and rusqlite refuses a count mismatch,
|
// would fail every desktop search.
|
||||||
// so every desktop search failed.
|
|
||||||
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
|
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
|
||||||
binds.push(format!("%{}%", escape_like(text)));
|
binds.push(format!("%{}%", escape_like(text)));
|
||||||
}
|
}
|
||||||
@@ -681,7 +675,7 @@ pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The longest snooze, in minutes: 30 days. The server's snooze route clamps to the
|
/// The longest snooze, in minutes: 30 days. The server's snooze route clamps to the
|
||||||
/// same range (`notes.SNOOZE_MAX_MINUTES`), so a device and the web agree (#5386).
|
/// same range (`notes.SNOOZE_MAX_MINUTES`), so a device and the web agree.
|
||||||
pub const SNOOZE_MAX_MINUTES: i64 = 60 * 24 * 30;
|
pub const SNOOZE_MAX_MINUTES: i64 = 60 * 24 * 30;
|
||||||
|
|
||||||
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> {
|
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> {
|
||||||
@@ -870,7 +864,7 @@ pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<()
|
|||||||
let total = ordered_ids.len() as i64;
|
let total = ordered_ids.len() as i64;
|
||||||
let at = now();
|
let at = now();
|
||||||
for (i, id) in ordered_ids.iter().enumerate() {
|
for (i, id) in ordered_ids.iter().enumerate() {
|
||||||
// A note someone shared with us moves on this account's board only (#5176),
|
// A note someone shared with us moves on this account's board only,
|
||||||
// stamped as its own state rather than as an edit.
|
// stamped as its own state rather than as an edit.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE notes
|
"UPDATE notes
|
||||||
@@ -1024,11 +1018,10 @@ pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
|
|||||||
|
|
||||||
/// Rename a label. Renaming ONTO a name another label already holds MERGES the two.
|
/// 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
|
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so a
|
||||||
/// the bare statement failed with a raw SQLite "UNIQUE constraint failed" that
|
/// bare statement fails with a raw SQLite "UNIQUE constraint failed". Merging is the
|
||||||
/// reached the user as database internals. Merging is the operator's call, and it
|
/// reading that matches what a person means — typing an existing tag's name onto
|
||||||
/// is the reading that matches what a person means — typing an existing tag's name
|
/// this one says "these are the same thing."
|
||||||
/// onto this one says "these are the same thing."
|
|
||||||
///
|
///
|
||||||
/// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one
|
/// 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
|
/// that already held the name" because age is the property neither participant's
|
||||||
@@ -1118,11 +1111,9 @@ pub fn merge_labels(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
//! The store had no tests of its own until two bugs shipped in it unnoticed:
|
//! Every test here runs a real query against a real schema: reading columns by
|
||||||
//! `load_note` reading its timestamps from the wrong columns after one was
|
//! the wrong position, or binding a pattern twice for one placeholder, is the
|
||||||
//! dropped (fa89da1), and the board's text facet binding its pattern twice for
|
//! kind of thing only that catches.
|
||||||
//! one placeholder (95aa10c). Both are the kind of thing only a real query
|
|
||||||
//! against a real schema catches, which is what every test here runs.
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -1158,7 +1149,7 @@ mod tests {
|
|||||||
notes.iter().map(|n| n.id.as_str()).collect()
|
notes.iter().map(|n| n.id.as_str()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- notes shared with this account (#5175) --------------------------------
|
// ---- notes shared with this account ----------------------------------------
|
||||||
|
|
||||||
/// A note someone else owns, as the feed leaves it: clean, and held at `permission`.
|
/// A note someone else owns, as the feed leaves it: clean, and held at `permission`.
|
||||||
fn shared(conn: &Connection, id: &str, permission: &str) {
|
fn shared(conn: &Connection, id: &str, permission: &str) {
|
||||||
@@ -1583,8 +1574,8 @@ mod tests {
|
|||||||
assert!(edited.labels.is_empty());
|
assert!(edited.labels.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every letter folds, accented ones included, as on the server (#5385). With
|
/// Every letter folds, accented ones included, as on the server; SQLite's lower()
|
||||||
/// SQLite's lower() the second note minted a "CAFÉ" tag of its own.
|
/// would mint a second "CAFÉ" tag.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_tag_spelled_with_different_accented_capitals_is_the_same_tag() {
|
fn a_tag_spelled_with_different_accented_capitals_is_the_same_tag() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user