Compare commits
+24
-5
@@ -1,4 +1,4 @@
|
||||
# ThoughtSync production settings. Copy to `.env` and edit:
|
||||
# Inkwell production settings. Copy to `.env` and edit:
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
@@ -29,15 +29,15 @@ POSTGRES_PASSWORD=
|
||||
#
|
||||
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
|
||||
# check which branch it actually landed on before assuming a bug.
|
||||
#THOUGHTSYNC_TAG=latest
|
||||
#INKWELL_TAG=latest
|
||||
|
||||
# The host port the app is published on.
|
||||
#THOUGHTSYNC_PORT=5000
|
||||
#INKWELL_PORT=5000
|
||||
|
||||
# Which interface to bind. The default (all interfaces) is what lets desktop
|
||||
# clients on your network reach the server. Behind a reverse proxy, set this to
|
||||
# 127.0.0.1 so only the proxy can talk to it.
|
||||
#THOUGHTSYNC_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 —
|
||||
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
|
||||
@@ -47,12 +47,31 @@ POSTGRES_PASSWORD=
|
||||
# How much the app says. Credential events (sign-ins, failures, throttles, new
|
||||
# accounts, device tokens issued) are logged at INFO and read with
|
||||
# `docker compose logs app`.
|
||||
#THOUGHTSYNC_LOG_LEVEL=INFO
|
||||
#INKWELL_LOG_LEVEL=INFO
|
||||
|
||||
# Database identity. Changing these AFTER the first start does not rename anything
|
||||
# that already exists — the volume keeps whatever the first run created.
|
||||
#POSTGRES_USER=inkwell
|
||||
#POSTGRES_DB=inkwell
|
||||
|
||||
# --- Upgrading from ThoughtSync ---------------------------------------------
|
||||
#
|
||||
# Inkwell was called ThoughtSync, and a deployment installed under that name has
|
||||
# its data in volumes and a database named for it. Point at them instead of
|
||||
# renaming anything. Leaving these unset on such a deployment starts Inkwell
|
||||
# against EMPTY volumes; the old data is untouched, but you would not see it.
|
||||
#
|
||||
# Use the full names `docker volume ls` prints — compose prefixes them with the
|
||||
# project name, so they usually look like `thoughtsync_thoughtsync-db`:
|
||||
#INKWELL_DB_VOLUME=thoughtsync_thoughtsync-db
|
||||
#INKWELL_DATA_VOLUME=thoughtsync_thoughtsync-data
|
||||
#
|
||||
# And the database identity the first start created, which a volume keeps:
|
||||
#POSTGRES_USER=thoughtsync
|
||||
#POSTGRES_DB=thoughtsync
|
||||
#
|
||||
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_* — the old
|
||||
# names are no longer read.
|
||||
|
||||
# --- a note on HTTPS --------------------------------------------------------
|
||||
#
|
||||
|
||||
@@ -4,7 +4,7 @@ name: Android
|
||||
#
|
||||
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
|
||||
# builds, not that Android has a lane: the UI is Compose, and the store and sync
|
||||
# engine are `thoughtsync-core` cross-compiled by cargo-ndk and loaded through
|
||||
# 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
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
echo "code=$code" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/inkwell-release.jks
|
||||
echo "variant=Release" >> $GITHUB_OUTPUT
|
||||
echo "label=release" >> $GITHUB_OUTPUT
|
||||
# DEBUG profile, in a release APK, deliberately — see the note above
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
# outright (run 4077). Unpicking that is worth doing and is not worth
|
||||
# blocking signed builds on.
|
||||
echo "profile=debug" >> $GITHUB_OUTPUT
|
||||
echo "keystore=/tmp/thoughtsync-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 "Signed release build — $version (versionCode $code)"
|
||||
else
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
# from the built .so. Run as its own step so a Rust failure is legible as a
|
||||
# Rust failure instead of arriving inside a Gradle stack trace.
|
||||
- name: Build the native library and bindings
|
||||
run: ./gradlew generateUniffiBindings -PTHOUGHTSYNC_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
|
||||
# (M12 step 3) precisely so this lane needs no second image, and going
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
# not exist (run 4082). It costs one extra Kotlin compile and buys the
|
||||
# type-check on the debug variant, which is the one an emulator build
|
||||
# would use.
|
||||
run: ./gradlew testDebugUnitTest -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||
run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||
|
||||
- name: Assemble the APK
|
||||
env:
|
||||
@@ -203,9 +203,9 @@ jobs:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
run: |
|
||||
./gradlew assemble${{ steps.build.outputs.variant }} \
|
||||
-PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
|
||||
-PTHOUGHTSYNC_VERSION_NAME=${{ steps.build.outputs.name }} \
|
||||
-PTHOUGHTSYNC_VERSION_CODE=${{ steps.build.outputs.code }}
|
||||
-PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
|
||||
-PINKWELL_VERSION_NAME=${{ steps.build.outputs.name }} \
|
||||
-PINKWELL_VERSION_CODE=${{ steps.build.outputs.code }}
|
||||
|
||||
# Prints the certificate the APK was actually signed with, so the operator
|
||||
# can compare it against the fingerprint recorded when the key was
|
||||
@@ -226,10 +226,10 @@ jobs:
|
||||
if: steps.build.outputs.keystore != ''
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp "app/build/outputs/apk/release/app-release.apk" dist/thoughtsync.apk
|
||||
size="$(wc -c < dist/thoughtsync.apk | tr -d ' ')"
|
||||
sha="$(sha256sum dist/thoughtsync.apk | cut -d' ' -f1)"
|
||||
cat > dist/thoughtsync-android.json <<JSON
|
||||
cp "app/build/outputs/apk/release/app-release.apk" dist/inkwell.apk
|
||||
size="$(wc -c < dist/inkwell.apk | tr -d ' ')"
|
||||
sha="$(sha256sum dist/inkwell.apk | cut -d' ' -f1)"
|
||||
cat > dist/inkwell-android.json <<JSON
|
||||
{
|
||||
"version_name": "${{ steps.build.outputs.name }}",
|
||||
"version_code": ${{ steps.build.outputs.code }},
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
"sha256": "$sha"
|
||||
}
|
||||
JSON
|
||||
cat dist/thoughtsync-android.json
|
||||
cat dist/inkwell-android.json
|
||||
|
||||
# The rolling channel for this branch, the same fixed-tag releases the desktop
|
||||
# bundles use. CI artifacts are per-run and auth-gated, so they are no use as a
|
||||
@@ -256,25 +256,27 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
case "$GITHUB_REF_NAME" in
|
||||
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
|
||||
main) channel=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||
esac
|
||||
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
||||
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||
export RELEASE_TAG RELEASE_PRERELEASE
|
||||
echo "Publishing the APK to the $RELEASE_TAG channel."
|
||||
bash desktop/packaging/publish-release.sh
|
||||
|
||||
- name: Upload the APK
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
|
||||
# it reports success while Gitea serves artifacts back only through the
|
||||
# v4 API, so the upload is stored and invisible. Pinned by SHA because
|
||||
# the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare
|
||||
# owner/repo to github.com. See Scribe issues 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
# Stock action: it works on this forge since the runner moved to
|
||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
||||
# success while Gitea serves artifacts back only through the v4 API, so the
|
||||
# upload is stored and invisible (Scribe 2270).
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
# The APK's variant, NOT the Cargo profile — those are the same word
|
||||
# for different things and the profile is pinned to debug (#2810).
|
||||
name: thoughtsync-android-${{ steps.build.outputs.label }}-${{ github.sha }}
|
||||
name: inkwell-android-${{ steps.build.outputs.label }}-${{ github.sha }}
|
||||
path: ${{ steps.build.outputs.apk }}
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
+41
-43
@@ -54,7 +54,7 @@ permissions:
|
||||
|
||||
env:
|
||||
REGISTRY: git.fabledsword.com
|
||||
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync
|
||||
IMAGE: git.fabledsword.com/bvandeusen/inkwell
|
||||
|
||||
jobs:
|
||||
# Should this push build an image now, or is the Android lane about to publish a
|
||||
@@ -143,8 +143,12 @@ jobs:
|
||||
echo "build=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# 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
|
||||
# lane (rule 177). The unit tests arrived with #5166; before them the web copy of
|
||||
# the note grammar, mirrored in Rust and Python, was guarded by nothing.
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
name: Web typecheck and unit tests
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
@@ -156,8 +160,14 @@ jobs:
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
# tsconfig.test.json, not the default: the default leaves the unit tests out
|
||||
# so the Docker build (which has only frontend/) can type-check the app.
|
||||
- name: Type check
|
||||
run: npx vue-tsc --noEmit
|
||||
run: npx vue-tsc --noEmit -p tsconfig.test.json
|
||||
working-directory: frontend
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
|
||||
lint:
|
||||
@@ -215,11 +225,11 @@ jobs:
|
||||
# Postgres it will actually meet.
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: thoughtsync
|
||||
POSTGRES_USER: inkwell
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: thoughtsync_test
|
||||
POSTGRES_DB: inkwell_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U thoughtsync"
|
||||
--health-cmd "pg_isready -U inkwell"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
@@ -243,7 +253,7 @@ jobs:
|
||||
test -n "$PG"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
test -n "$PG_IP"
|
||||
export THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_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) —
|
||||
# no bash /dev/tcp — so use the Python that is always present here.
|
||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||
@@ -323,53 +333,41 @@ jobs:
|
||||
docker system prune -af || true
|
||||
docker builder prune --keep-storage 5g -f || true
|
||||
|
||||
# Bake the Android client in, on EVERY image build, so :dev, :latest and
|
||||
# :<version> all carry one and a `docker compose pull` delivers a new client
|
||||
# along with the new server.
|
||||
# Bake EVERY client in, on every image build, so a self-hoster gets a working
|
||||
# app for their machine from the server holding their notes — without an
|
||||
# account on this forge, which is private (issue 2091) and is why serving them
|
||||
# from a release page was never an option for anybody but the operator.
|
||||
#
|
||||
# Always the rolling `dev` release — the newest build there is. A versioned
|
||||
# image therefore carries the newest client rather than one pinned to that
|
||||
# version; the two negotiate a sync protocol version before linking, so
|
||||
# "newest" is safe in a way "matching" would not buy anything over.
|
||||
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
|
||||
# the price of the product being complete (rule 23), and the AppImage is not
|
||||
# optional within it: it is the ONLY bundle that can replace itself in place,
|
||||
# so a server without one cannot serve in-app updates to anyone.
|
||||
#
|
||||
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
|
||||
# Fetched by the JOB, not by the Dockerfile: the releases are private, and a
|
||||
# token used inside a build lands in the context or a layer.
|
||||
#
|
||||
# NEVER fails the build. An image with no Android client advertises none and
|
||||
# hides the download — a supported state, and the only one available before
|
||||
# the first Android build has ever published.
|
||||
- name: Fetch the Android client to bake in
|
||||
# NEVER fails the build — see the script. A platform with nothing published
|
||||
# means the server advertises nothing for it and the UI hides that download,
|
||||
# which is a supported state and the only one available before that platform's
|
||||
# first build has ever published.
|
||||
- name: Fetch the clients to bake in
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
mkdir -p client
|
||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev
|
||||
# client; :latest serves the stable one. This read `download/dev`
|
||||
# unconditionally until M314 step 3, on every branch — so every stable
|
||||
# server shipped a dev-channel APK to anyone who downloaded the client
|
||||
# from it. Not a versioning gap; a plain defect, fixed here because this
|
||||
# is the step that gave `stable` an APK to point at.
|
||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
|
||||
# :latest serves stable ones. This read `download/dev` unconditionally
|
||||
# until M314 step 3, on every branch — so every stable server shipped a
|
||||
# dev-channel APK to anyone who downloaded the client from it. Not a
|
||||
# versioning gap; a plain defect, and the reason the channel is chosen here
|
||||
# rather than inside the script: the caller is what knows which image it is
|
||||
# building.
|
||||
case "${{ github.ref_name }}" in
|
||||
main) channel=stable ;;
|
||||
*) channel=dev ;;
|
||||
esac
|
||||
echo "Baking in the $channel client."
|
||||
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
|
||||
ok=1
|
||||
for f in thoughtsync.apk thoughtsync-android.json; do
|
||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo "Baking in:"
|
||||
cat client/thoughtsync-android.json
|
||||
ls -l client/thoughtsync.apk
|
||||
else
|
||||
# Both or neither. Half a pair is worse than none: the server would
|
||||
# read a sidecar describing an APK that isn't there, or an APK it
|
||||
# cannot state a version for.
|
||||
echo "::warning::No Android client on the dev release — this image ships without one."
|
||||
rm -f client/thoughtsync.apk client/thoughtsync-android.json
|
||||
fi
|
||||
sh packaging/fetch-clients.sh "$channel" client
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
+116
-45
@@ -75,41 +75,47 @@ jobs:
|
||||
sh packaging/guard-forward.sh desktop "$channel"
|
||||
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
name: Tauri desktop (Linux)
|
||||
# The workspace checks, in their OWN job, so that BOTH publishing jobs can need
|
||||
# them (rule 177: nothing publishes on red).
|
||||
#
|
||||
# They used to be steps inside `build`, which gated the Linux publish and nothing
|
||||
# else. `windows` needed only `decide`, so on run 8411 clippy failed, the Linux job
|
||||
# stopped, and the Windows installer built and published to the dev release in the
|
||||
# same minute (#5184). An edge from each publisher to this job is the gate; a
|
||||
# verdict inside a sibling job is only a report.
|
||||
#
|
||||
# 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
|
||||
# bundling. No `always()` or `continue-on-error` anywhere on this path: a skipped
|
||||
# or failed verify must leave both publishers skipped.
|
||||
verify:
|
||||
name: Web tests, clippy, Rust tests and rustfmt
|
||||
needs: [decide]
|
||||
if: needs.decide.outputs.build == 'true'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||
env:
|
||||
# AppImage tooling (linuxdeploy) FUSE-mounts itself by default; CI containers
|
||||
# have no /dev/fuse, so tell it to extract-and-run instead. Without this the
|
||||
# AppImage bundle step fails with a FUSE error.
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
steps:
|
||||
# No version is derived here, so the default shallow checkout is enough.
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||
# emitting something plausible, which is what turns this into a red lane
|
||||
# if it is ever dropped.
|
||||
fetch-depth: 0
|
||||
|
||||
# tauri's generate_context! embeds the built frontend at compile time, so the
|
||||
# frontend must exist before any cargo compile (clippy/test/build), not just
|
||||
# at bundle time.
|
||||
# frontend must exist before clippy or the tests can compile the desktop crate.
|
||||
- name: Build the shared frontend
|
||||
run: npm ci && npm run build
|
||||
working-directory: frontend
|
||||
|
||||
# --locked on the FIRST cargo invocation of the job is the lockfile gate: it
|
||||
# fails the run if Cargo.toml and the committed Cargo.lock disagree, instead
|
||||
# of silently re-resolving. Everything after it in this job then compiles the
|
||||
# exact versions recorded in the lockfile, so the flag isn't repeated on the
|
||||
# bundle build (issue 2102).
|
||||
# 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,
|
||||
# a red web test would still let both installers publish (rule 177).
|
||||
- name: Web unit tests
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
|
||||
# --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
|
||||
# re-resolving (issue 2102). Both publishing jobs need this job, so neither
|
||||
# bundles a commit whose lockfile drifted.
|
||||
#
|
||||
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri.
|
||||
#
|
||||
@@ -132,11 +138,40 @@ jobs:
|
||||
# but there is no Rust toolchain on the workstation (the desktop lane is
|
||||
# verified entirely here), so a formatting nit failing first SKIPS clippy and
|
||||
# the tests, and one CI cycle teaches nothing but whitespace. Running it here
|
||||
# means every push reports its real problems too. Still before the ~20-40 min
|
||||
# bundle build, so a fmt failure doesn't burn that.
|
||||
# means every push reports its real problems too. Still before either bundle
|
||||
# build, so a fmt failure doesn't burn one.
|
||||
- name: Rust format check
|
||||
run: cargo fmt --all --check
|
||||
|
||||
build:
|
||||
name: Tauri desktop (Linux)
|
||||
needs: [decide, verify]
|
||||
if: needs.decide.outputs.build == 'true'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||
env:
|
||||
# AppImage tooling (linuxdeploy) FUSE-mounts itself by default; CI containers
|
||||
# have no /dev/fuse, so tell it to extract-and-run instead. Without this the
|
||||
# AppImage bundle step fails with a FUSE error.
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||
# emitting something plausible, which is what turns this into a red lane
|
||||
# if it is ever dropped.
|
||||
fetch-depth: 0
|
||||
|
||||
# tauri's generate_context! embeds the built frontend at compile time, so the
|
||||
# frontend must exist before cargo compiles anything, not just at bundle time.
|
||||
- name: Build the shared frontend
|
||||
run: npm ci && npm run build
|
||||
working-directory: frontend
|
||||
|
||||
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
||||
#
|
||||
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
|
||||
@@ -162,6 +197,14 @@ jobs:
|
||||
# separate value and arrives with the UI that shows it (#3181).
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||
# A different value for a different audience: this is the one a person
|
||||
# quotes in a bug report, the key above is the one only a comparator
|
||||
# sees. Exported rather than passed as a flag because the macro that
|
||||
# reads it is in Rust source, not in Tauri's config.
|
||||
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
|
||||
export INKWELL_DISPLAY_VERSION
|
||||
echo "Baking display version $INKWELL_DISPLAY_VERSION"
|
||||
cargo tauri build \
|
||||
--config '{"build":{"beforeBuildCommand":""}}' \
|
||||
--config "{\"version\":\"$version\"}" \
|
||||
@@ -222,18 +265,17 @@ jobs:
|
||||
run: bash desktop/packaging/arch/package-prebuilt.sh
|
||||
|
||||
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
|
||||
# Mirrored action, never actions/upload-artifact: @v4+ throws
|
||||
# GHESNotSupportedError on the hostname before it connects, and @v3 uploads
|
||||
# Stock action: it works on this forge since the runner moved to
|
||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it uploads
|
||||
# something Gitea stores but will never serve back (it returns artifacts only
|
||||
# through the v4 API, which filters on content_encoding='application/zip').
|
||||
# Pinned by SHA — the mirror auto-syncs, so a moved upstream tag would
|
||||
# silently change what runs. See Scribe issues 2255 / 2270.
|
||||
# No continue-on-error: a swallowed upload failure is exactly how 110
|
||||
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
|
||||
- name: Upload bundles
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: thoughtsync-linux
|
||||
name: inkwell-linux
|
||||
path: |
|
||||
target/release/bundle/appimage/*.AppImage
|
||||
target/release/bundle/deb/*.deb
|
||||
@@ -271,9 +313,12 @@ jobs:
|
||||
# `prerelease` is true for dev so it does not read as a supported build,
|
||||
# and false for stable, which is the real thing.
|
||||
case "$GITHUB_REF_NAME" in
|
||||
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
|
||||
main) channel=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||
esac
|
||||
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
||||
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||
export RELEASE_TAG RELEASE_PRERELEASE
|
||||
echo "Publishing to the $RELEASE_TAG channel."
|
||||
bash desktop/packaging/publish-release.sh
|
||||
@@ -293,7 +338,7 @@ jobs:
|
||||
# built, not that it runs. A real-machine check stays mandatory before trusting it.
|
||||
windows:
|
||||
name: Windows installer (cross-compiled)
|
||||
needs: [decide]
|
||||
needs: [decide, verify]
|
||||
if: needs.decide.outputs.build == 'true'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
@@ -324,8 +369,8 @@ jobs:
|
||||
run: cargo tauri icon app-icon.png
|
||||
working-directory: desktop/src-tauri
|
||||
|
||||
# This lane's lockfile gate (the Linux job gets it from `cargo clippy
|
||||
# --locked`). It has to be its own step here because the build is this job's
|
||||
# This lane's own lockfile check (`verify` has already run `cargo clippy
|
||||
# --locked` for the workspace). It has to be its own step here because the build is this job's
|
||||
# only crate-graph command, and discovering the drift 30 minutes into a
|
||||
# cross-compile is the expensive way to learn it. Fetching for the Windows
|
||||
# target also pre-warms exactly the crates the build will want.
|
||||
@@ -347,6 +392,14 @@ jobs:
|
||||
# separate value and arrives with the UI that shows it (#3181).
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||
# A different value for a different audience: this is the one a person
|
||||
# quotes in a bug report, the key above is the one only a comparator
|
||||
# sees. Exported rather than passed as a flag because the macro that
|
||||
# reads it is in Rust source, not in Tauri's config.
|
||||
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
|
||||
export INKWELL_DISPLAY_VERSION
|
||||
echo "Baking display version $INKWELL_DISPLAY_VERSION"
|
||||
updater='{}'
|
||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||
updater='{"bundle":{"createUpdaterArtifacts":true}}'
|
||||
@@ -360,13 +413,11 @@ jobs:
|
||||
--config "$updater"
|
||||
working-directory: desktop/src-tauri
|
||||
|
||||
# Mirrored action, never actions/upload-artifact — see the Linux job's
|
||||
# Upload bundles step for the full reasoning. Pinned by SHA because the
|
||||
# mirror auto-syncs.
|
||||
# Stock action — see the Linux job's Upload bundles step for why.
|
||||
- name: Upload installer
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: thoughtsync-windows
|
||||
name: inkwell-windows
|
||||
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -399,9 +450,12 @@ jobs:
|
||||
# `prerelease` is true for dev so it does not read as a supported build,
|
||||
# and false for stable, which is the real thing.
|
||||
case "$GITHUB_REF_NAME" in
|
||||
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
|
||||
main) channel=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||
esac
|
||||
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
||||
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||
export RELEASE_TAG RELEASE_PRERELEASE
|
||||
echo "Publishing to the $RELEASE_TAG channel."
|
||||
bash desktop/packaging/publish-release.sh
|
||||
@@ -447,6 +501,12 @@ jobs:
|
||||
# match the binary it points at is an updater that never settles. It must
|
||||
# be `key`: this value is matched against bundle filenames.
|
||||
version="$(sh packaging/version.sh key desktop)"
|
||||
# The version a PERSON reads, published beside the manifest as
|
||||
# `inkwell-desktop.json`. The image build reads it to describe the
|
||||
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
|
||||
# anything from its own checkout — which would be a different commit
|
||||
# whenever the desktop did not rebuild.
|
||||
display="$(sh packaging/version.sh display desktop)"
|
||||
# Both channels are rolling: the manifest lands on the same release that
|
||||
# holds the bundles, and the previous build's bundles are dropped once it
|
||||
# points at this one. Nothing can reach them, and they are ~100 MB a push.
|
||||
@@ -454,10 +514,21 @@ jobs:
|
||||
# No tag arm any more. A `v*` tag does not reach this workflow at all — it
|
||||
# triggers release.yml, which writes a changelog and builds nothing.
|
||||
case "${GITHUB_REF_NAME}" in
|
||||
main) export RELEASE_TAG=stable
|
||||
main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
|
||||
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
|
||||
*) export RELEASE_TAG=dev
|
||||
*) RELEASE_TAG="$(sh packaging/channel-tag.sh dev)"
|
||||
# TEMPORARY one-shot bridge (Scribe #2184). Desktop apps installed
|
||||
# before the rename have .../download/dev/latest.json compiled in,
|
||||
# so the manifest is also written to the old `dev` release; its
|
||||
# URLs name dev-rolling assets, so those apps update once into a
|
||||
# build that reads the new tag. Delete this line together with the
|
||||
# old `dev` release and tag.
|
||||
export BRIDGE_TAG=dev
|
||||
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
|
||||
esac
|
||||
# Assigned bare above so a failing channel-tag.sh fails this step;
|
||||
# `export X="$(...)"` would swallow its exit status.
|
||||
export RELEASE_TAG
|
||||
export PRUNE_OLD_ASSETS=true
|
||||
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
||||
APP_VERSION="$version" DISPLAY_VERSION="$display" \
|
||||
bash desktop/packaging/write-manifest.sh
|
||||
|
||||
+2
-2
@@ -209,5 +209,5 @@ android/local.properties
|
||||
# The Android client CI bakes into the server image. Fetched fresh on every image
|
||||
# build, so it is never worth 55 MiB of git history. client/.keep IS tracked, so
|
||||
# the Dockerfile's COPY always has a directory to copy.
|
||||
client/thoughtsync.apk
|
||||
client/thoughtsync-android.json
|
||||
client/inkwell.apk
|
||||
client/inkwell-android.json
|
||||
|
||||
Generated
+681
-59
File diff suppressed because it is too large
Load Diff
+16
-10
@@ -20,22 +20,28 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
# Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH
|
||||
# points at /app/src so the runtime imports this source tree (with static/ present),
|
||||
# not the pip-installed copy.
|
||||
COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
|
||||
COPY --from=build-frontend /build/dist/ src/inkwell/static/
|
||||
COPY alembic.ini .
|
||||
COPY alembic/ alembic/
|
||||
|
||||
# The Android client this server hands out. CI fetches the newest published build
|
||||
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
|
||||
# :latest and :<version> alike — ships a client, and a `docker compose pull`
|
||||
# delivers a new one with no file copying by hand.
|
||||
# The clients this server hands out — the APK and all four desktop bundles. CI
|
||||
# fetches the newest published build of each into ./client immediately before this
|
||||
# runs (packaging/fetch-clients.sh), so both image tags ship a full set and a
|
||||
# `docker compose pull` delivers new ones with no file copying by hand.
|
||||
#
|
||||
# Fetched by the JOB rather than here on purpose: the release is private, and a
|
||||
# ~104 MB of this image is that set, almost all of it the AppImage.
|
||||
#
|
||||
# Fetched by the JOB rather than here on purpose: the releases are private, and a
|
||||
# token used inside a build ends up in the build context or a layer.
|
||||
#
|
||||
# LAST of the COPYs, deliberately: this directory changes on every build, so
|
||||
# putting it above the `pip install` layer would invalidate that layer every time.
|
||||
#
|
||||
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
|
||||
# that step never ran. An image with no APK is a supported state — the server
|
||||
# advertises nothing and the web UI hides the download (client_dist.py).
|
||||
COPY client/ src/thoughtsync/client/
|
||||
# that step never ran. An image with no clients — or with some and not others — is
|
||||
# a supported state: the server advertises what it has and the web UI hides the
|
||||
# rest (client_dist.py).
|
||||
COPY client/ src/inkwell/client/
|
||||
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
@@ -46,4 +52,4 @@ EXPOSE 5000
|
||||
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
|
||||
# slow/unready database from crash-looping the container. Family convention
|
||||
# (rule 82): schema is built by real migrations, never metadata.create_all.
|
||||
CMD ["sh", "-c", "python -m thoughtsync.dbwait && alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||
CMD ["sh", "-c", "python -m inkwell.dbwait && alembic upgrade head && hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ThoughtSync
|
||||
# Inkwell
|
||||
|
||||
Self-hosted personal thought-capture web app in the **FabledSword** family — a
|
||||
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
|
||||
@@ -13,7 +13,7 @@ under a second, designed to grow into a lightweight second brain (labels, search
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db)
|
||||
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
|
||||
@@ -23,12 +23,12 @@ docker-compose.yml local app + Postgres stack
|
||||
|
||||
## Development
|
||||
|
||||
Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`):
|
||||
Backend (needs a Postgres reachable at `INKWELL_DATABASE_URL`):
|
||||
|
||||
```sh
|
||||
pip install -e ".[dev]"
|
||||
alembic upgrade head
|
||||
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000
|
||||
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
|
||||
```
|
||||
|
||||
Frontend (proxies `/api` to `:5000`):
|
||||
@@ -64,40 +64,40 @@ services:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: thoughtsync
|
||||
POSTGRES_USER: inkwell
|
||||
POSTGRES_PASSWORD: CHANGE_ME # change this
|
||||
POSTGRES_DB: thoughtsync
|
||||
POSTGRES_DB: inkwell
|
||||
volumes:
|
||||
- thoughtsync-db:/var/lib/postgresql/data
|
||||
- inkwell-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
|
||||
test: ["CMD-SHELL", "pg_isready -U inkwell"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
image: git.fabledsword.com/bvandeusen/thoughtsync:latest # :dev for the current dev build
|
||||
image: git.fabledsword.com/bvandeusen/inkwell:latest # :dev for the current dev build
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync
|
||||
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
|
||||
volumes:
|
||||
- thoughtsync-data:/var/thoughtsync # uploaded images; omit if you don't use attachments
|
||||
- inkwell-data:/var/inkwell # uploaded images; omit if you don't use attachments
|
||||
ports:
|
||||
- "5000:5000"
|
||||
|
||||
volumes:
|
||||
thoughtsync-db:
|
||||
thoughtsync-data:
|
||||
inkwell-db:
|
||||
inkwell-data:
|
||||
```
|
||||
|
||||
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
|
||||
|
||||
- **Only `THOUGHTSYNC_DATABASE_URL` is required.** `THOUGHTSYNC_SECRET_KEY` is optional; if
|
||||
- **Only `INKWELL_DATABASE_URL` is required.** `INKWELL_SECRET_KEY` is optional; if
|
||||
unset, a signing key is generated and persisted in the database (sessions survive restarts).
|
||||
- Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`.
|
||||
- Uploaded images live under the `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
|
||||
@@ -108,7 +108,7 @@ Then open `http://<host>:5000` and register — **the first account becomes the
|
||||
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):** ThoughtSync is installable ("Add to Home Screen" / the
|
||||
- **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.
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
# Alembic single-database async configuration for ThoughtSync.
|
||||
# Alembic single-database async configuration for Inkwell.
|
||||
|
||||
[alembic]
|
||||
script_location = %(here)s/alembic
|
||||
prepend_sys_path = . src
|
||||
path_separator = os
|
||||
|
||||
# Local-dev default; overridden at runtime by THOUGHTSYNC_DATABASE_URL (see env.py).
|
||||
sqlalchemy.url = postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync
|
||||
# Local-dev default; overridden at runtime by INKWELL_DATABASE_URL (see env.py).
|
||||
sqlalchemy.url = postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell
|
||||
|
||||
|
||||
[loggers]
|
||||
|
||||
+3
-3
@@ -8,8 +8,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
from thoughtsync.models import Base
|
||||
import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata
|
||||
from inkwell.models import Base
|
||||
import inkwell.models.all # noqa: F401 — registers every model on Base.metadata
|
||||
|
||||
config = context.config
|
||||
|
||||
@@ -18,7 +18,7 @@ if config.config_file_name is not None:
|
||||
|
||||
config.set_main_option(
|
||||
"sqlalchemy.url",
|
||||
os.environ.get("THOUGHTSYNC_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
|
||||
os.environ.get("INKWELL_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
|
||||
)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""a stored site_name of the old default follows the rename to Inkwell
|
||||
|
||||
Revision ID: 0030
|
||||
Revises: 0029
|
||||
Create Date: 2026-10-06
|
||||
|
||||
The product is renamed ThoughtSync → Inkwell (Scribe note 5071), and the `site_name`
|
||||
registry default moves with it. On its own that only reaches servers whose settings
|
||||
table has no `site_name` row — and most servers have one, because the Settings page
|
||||
saves EVERY key on Save, not just the one that changed. An admin who opened Settings
|
||||
to flip registration off has persisted `"ThoughtSync"` without ever choosing it.
|
||||
|
||||
So the row is rewritten, but only when it holds exactly the old default. A name the
|
||||
admin actually typed is theirs and is left alone; the old default is the one value we
|
||||
can be sure nobody chose.
|
||||
|
||||
The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so
|
||||
`'"ThoughtSync"'` is the whole of what it looks for.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Puts the old default back on a row holding exactly the new one. A server where the
|
||||
admin typed "Inkwell" themselves between the two cannot be told apart from one this
|
||||
migration rewrote; on downgrade both read "ThoughtSync", which is what the old code
|
||||
would have shown for either.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0030"
|
||||
down_revision = "0029"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_OLD = '"ThoughtSync"'
|
||||
_NEW = '"Inkwell"'
|
||||
|
||||
|
||||
def _swap(old: str, new: str) -> None:
|
||||
op.get_bind().execute(
|
||||
sa.text("UPDATE settings SET value = :new WHERE key = 'site_name' AND value = :old"),
|
||||
{"old": old, "new": new},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_swap(_OLD, _NEW)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_swap(_NEW, _OLD)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""a link preview's insert, update or delete bumps its note's sync revision
|
||||
|
||||
Revision ID: 0031
|
||||
Revises: 0030
|
||||
Create Date: 2026-10-07
|
||||
|
||||
0015 made every child table bump its parent note's `sync_revision`, so a note syncs
|
||||
as a whole. `note_link_previews` arrived later (0020) and was never added, and two
|
||||
things have been missing on every linked device since:
|
||||
|
||||
- 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
|
||||
|
||||
Drops the trigger. Previews go back to not propagating; nothing is lost.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0031"
|
||||
down_revision = "0030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"CREATE TRIGGER trg_note_link_previews_bump_note AFTER INSERT OR UPDATE OR DELETE "
|
||||
"ON note_link_previews FOR EACH ROW EXECUTE PROCEDURE ts_bump_parent_note_revision()"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TRIGGER IF EXISTS trg_note_link_previews_bump_note ON note_link_previews")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""invites: single-use, expiring registration links an admin issues
|
||||
|
||||
Revision ID: 0032
|
||||
Revises: 0031
|
||||
Create Date: 2026-10-07
|
||||
|
||||
Until now the only way to add a second person was to re-open registration to the
|
||||
whole internet while they signed up (#2939 §1). An invite lets one person register
|
||||
while registration stays closed. Only the token's SHA-256 hash is stored.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Drops the table. Accounts created through invites are untouched; the record of who
|
||||
invited them goes with it.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import CITEXT, UUID
|
||||
|
||||
revision = "0032"
|
||||
down_revision = "0031"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"invites",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
|
||||
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("email", CITEXT(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("redeemed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("invites")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""password resets: an admin-issued, one-hour link; sessions an account can outlive
|
||||
|
||||
Revision ID: 0033
|
||||
Revises: 0032
|
||||
Create Date: 2026-10-07
|
||||
|
||||
A forgotten password used to need a hand on the database (#2939 §2), and the app has
|
||||
no mail path to send a reset by. An admin makes a reset link for the account and
|
||||
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
|
||||
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.
|
||||
It starts at 0, the value a cookie from before this migration is read as, so nobody
|
||||
is signed out by the upgrade itself.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Drops the table and the column. Outstanding reset links stop working; sessions keep
|
||||
working, since nothing checks an epoch any more.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "0033"
|
||||
down_revision = "0032"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0"))
|
||||
op.create_table(
|
||||
"password_resets",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_password_resets_user_id", table_name="password_resets")
|
||||
op.drop_table("password_resets")
|
||||
op.drop_column("users", "session_epoch")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""shares: one grant per note and person, and only the permissions the app knows
|
||||
|
||||
Revision ID: 0034
|
||||
Revises: 0033
|
||||
Create Date: 2026-10-07
|
||||
|
||||
Nothing wrote a share until #5174, so the table never needed these. Now the Share
|
||||
dialog does:
|
||||
- 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
|
||||
stacking two (the same for a group, ahead of step 15).
|
||||
- A check that `permission` is `view` or `edit`.
|
||||
- An index on `shared_with_user_id` for "Shared with me".
|
||||
|
||||
## Downgrade
|
||||
|
||||
Drops the indexes and the check. The rows stay.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0034"
|
||||
down_revision = "0033"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"uq_shares_resource_user",
|
||||
"shares",
|
||||
["resource_type", "resource_id", "shared_with_user_id"],
|
||||
unique=True,
|
||||
postgresql_where="shared_with_user_id IS NOT NULL",
|
||||
)
|
||||
op.create_index(
|
||||
"uq_shares_resource_group",
|
||||
"shares",
|
||||
["resource_type", "resource_id", "shared_with_group_id"],
|
||||
unique=True,
|
||||
postgresql_where="shared_with_group_id IS NOT NULL",
|
||||
)
|
||||
op.create_index("ix_shares_user", "shares", ["shared_with_user_id"])
|
||||
op.create_check_constraint("ck_shares_permission", "shares", "permission IN ('view', 'edit')")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_shares_permission", "shares", type_="check")
|
||||
op.drop_index("ix_shares_user", table_name="shares")
|
||||
op.drop_index("uq_shares_resource_group", table_name="shares")
|
||||
op.drop_index("uq_shares_resource_user", table_name="shares")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""share_revocations: telling a recipient's devices a shared note has left them
|
||||
|
||||
Revision ID: 0035
|
||||
Revises: 0034
|
||||
Create Date: 2026-10-07
|
||||
|
||||
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
|
||||
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,
|
||||
stamped from the same `sync_revision_seq` the notes and labels draw from, so it sits
|
||||
on the one cursor every client already pages by.
|
||||
|
||||
Sharing the note with that person again deletes the row, so a device that never
|
||||
heard of the revocation never gets told to delete a note it is meant to have.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Drops the table. Devices that missed a revocation keep their copy.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "0035"
|
||||
down_revision = "0034"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"share_revocations",
|
||||
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column(
|
||||
"sync_revision",
|
||||
sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default=sa.text("nextval('sync_revision_seq')"),
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint("note_id", "user_id"),
|
||||
)
|
||||
op.create_index("ix_share_revocations_user_revision", "share_revocations", ["user_id", "sync_revision"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_share_revocations_user_revision", table_name="share_revocations")
|
||||
op.drop_table("share_revocations")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""note_user_state: a recipient's own pin, archive and place for a shared note
|
||||
|
||||
Revision ID: 0036
|
||||
Revises: 0035
|
||||
Create Date: 2026-10-07
|
||||
|
||||
Pin, archive and board order are personal organization, and until now they were
|
||||
columns on `notes`, so on a shared note they could only ever mean the owner's
|
||||
(#5176). This table holds them for everyone else: one row per (note, person it is
|
||||
shared with) who has pinned, archived or moved it. The owner keeps the note's own
|
||||
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
|
||||
and labels use (0015), so a recipient's change reaches their own devices on the one
|
||||
cursor they already page by, and never moves the note on anyone else's.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Drops the table. Recipients lose their own pins and archives; the notes are
|
||||
untouched.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "0036"
|
||||
down_revision = "0035"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"note_user_state",
|
||||
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("position", sa.Integer(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("sync_revision", sa.BigInteger(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("note_id", "user_id"),
|
||||
)
|
||||
op.create_index("ix_note_user_state_user_revision", "note_user_state", ["user_id", "sync_revision"])
|
||||
op.execute(
|
||||
"CREATE TRIGGER trg_note_user_state_sync_revision BEFORE INSERT OR UPDATE ON note_user_state "
|
||||
"FOR EACH ROW EXECUTE PROCEDURE ts_set_sync_revision()"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TRIGGER IF EXISTS trg_note_user_state_sync_revision ON note_user_state")
|
||||
op.drop_index("ix_note_user_state_user_revision", table_name="note_user_state")
|
||||
op.drop_table("note_user_state")
|
||||
@@ -16,7 +16,7 @@ val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
|
||||
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
|
||||
/**
|
||||
* Cross-compile `thoughtsync-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.
|
||||
*
|
||||
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
|
||||
@@ -49,7 +49,7 @@ abstract class CargoNdkBuild : DefaultTask() {
|
||||
args += "-t"
|
||||
args += abi
|
||||
}
|
||||
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "thoughtsync-ffi")
|
||||
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "inkwell-ffi")
|
||||
// --locked so an Android build cannot silently re-resolve the workspace
|
||||
// lockfile the desktop lanes are gated on.
|
||||
args += "--locked"
|
||||
@@ -94,7 +94,7 @@ abstract class UniffiBindgen : DefaultTask() {
|
||||
"run",
|
||||
"--locked",
|
||||
"-p",
|
||||
"thoughtsync-uniffi-bindgen",
|
||||
"inkwell-uniffi-bindgen",
|
||||
"--",
|
||||
"generate",
|
||||
"--library",
|
||||
@@ -141,7 +141,7 @@ val rustInputs =
|
||||
* build, and neither is worth holding signed APKs up for. Scribe #2810.
|
||||
*/
|
||||
val rustProfile =
|
||||
(project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
||||
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
||||
?: "debug"
|
||||
|
||||
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
|
||||
@@ -149,7 +149,7 @@ val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
|
||||
|
||||
val cargoNdk =
|
||||
tasks.register<CargoNdkBuild>("cargoNdk") {
|
||||
description = "Cross-compile thoughtsync-ffi for the Android ABIs."
|
||||
description = "Cross-compile inkwell-ffi for the Android ABIs."
|
||||
rustSources.from(rustInputs)
|
||||
abis.set(androidAbis)
|
||||
cargoProfile.set(rustProfile)
|
||||
@@ -163,17 +163,17 @@ val generateBindings =
|
||||
dependsOn(cargoNdk)
|
||||
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
|
||||
// reading one is cheaper than reading four.
|
||||
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") })
|
||||
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libinkwell_ffi.so") })
|
||||
workspaceDir.set(workspaceRoot)
|
||||
outputDir.set(bindingsOut)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fabledsword.thoughtsync"
|
||||
namespace = "com.fabledsword.inkwell"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.fabledsword.thoughtsync"
|
||||
applicationId = "com.fabledsword.inkwell"
|
||||
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
|
||||
// room to spare.
|
||||
minSdk = 26
|
||||
@@ -181,9 +181,9 @@ android {
|
||||
// Injected by CI from the git tag + commit count for a release; "dev"
|
||||
// locally so the About screen reads honestly rather than claiming 1.0.
|
||||
val nameOverride =
|
||||
(project.findProperty("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||
(project.findProperty("INKWELL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||
val codeOverride =
|
||||
(project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull()
|
||||
(project.findProperty("INKWELL_VERSION_CODE") as String?)?.toIntOrNull()
|
||||
versionCode = codeOverride ?: 1
|
||||
versionName = nameOverride ?: "dev"
|
||||
|
||||
@@ -215,7 +215,10 @@ android {
|
||||
// Hardcoded, and NOT a secret: the alias is fixed for the life of
|
||||
// this app and is written into the certificate every install
|
||||
// already carries. Hiding it would buy nothing and stop this file
|
||||
// describing its own signing setup.
|
||||
// describing its own signing setup. It still says `thoughtsync`
|
||||
// after the rename to Inkwell, because it names the key inside the
|
||||
// existing keystore, and renaming it would only stop that key being
|
||||
// found. Same key, so the signing certificate did not change either.
|
||||
keyAlias = "thoughtsync"
|
||||
// PKCS12 cannot hold a key password distinct from the store
|
||||
// password — keytool refuses to set one — so this is the same
|
||||
|
||||
Vendored
+1
-1
@@ -4,4 +4,4 @@
|
||||
# is the worst possible time to learn it.
|
||||
-keep class com.sun.jna.** { *; }
|
||||
-keepclassmembers class * extends com.sun.jna.** { public *; }
|
||||
-keep class com.fabledsword.thoughtsync.core.** { *; }
|
||||
-keep class com.fabledsword.inkwell.core.** { *; }
|
||||
|
||||
@@ -91,23 +91,71 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name=".ThoughtSyncApplication"
|
||||
android:name=".InkwellApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ThoughtSync"
|
||||
android:theme="@style/Theme.Inkwell"
|
||||
android:usesCleartextTraffic="true">
|
||||
<!--
|
||||
launchMode="singleTop" exists for the SHARE filters below.
|
||||
|
||||
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
|
||||
intent, so onNewIntent already worked for that one. A share intent is
|
||||
built by the OTHER app — Chrome, a reader, the text-selection toolbar —
|
||||
and nothing here can add a flag to it. Without singleTop declared on the
|
||||
activity itself, every share while the app is running would stack a
|
||||
second MainActivity on top of the first: a second view model, a second
|
||||
board, and a back press that lands on a stale copy of the same app.
|
||||
-->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.ThoughtSync">
|
||||
android:theme="@style/Theme.Inkwell">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!--
|
||||
Capture without opening the app first: Share → Inkwell from
|
||||
anywhere, and the selection toolbar in any text field.
|
||||
|
||||
Text, 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
|
||||
and uploaded on the next sync that reaches a server (#5169).
|
||||
|
||||
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 sheet for APKs, contacts and calendar entries, where it would
|
||||
be noise. Other files attach from inside the editor, whose picker
|
||||
takes any type.
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!--
|
||||
The label is what appears in the text-selection menu beside Copy and
|
||||
Share, where "Inkwell" would say who rather than what.
|
||||
-->
|
||||
<intent-filter android:label="@string/capture_process_text">
|
||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
@@ -126,6 +174,22 @@
|
||||
PackageInstaller. Without it a failed install would be indistinguishable
|
||||
from someone declining the dialog (Scribe #2438).
|
||||
-->
|
||||
<!--
|
||||
Hands an attachment to the app that opens its type. Not exported, as a
|
||||
FileProvider must not be: access is granted one URI at a time, on the
|
||||
intent that opens it. It serves only the cache copies listed in
|
||||
res/xml/file_paths.xml, never the note store.
|
||||
-->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name=".UpdateReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -42,7 +42,7 @@ import java.io.File
|
||||
* [UpdateReceiver], which is why a failure can be shown rather than guessed at.
|
||||
*/
|
||||
object AppUpdate {
|
||||
private const val TAG = "ThoughtSyncUpdate"
|
||||
private const val TAG = "InkwellUpdate"
|
||||
|
||||
/** This build's versionCode — what the server's is compared against. */
|
||||
fun installedVersionCode(context: Context): Long =
|
||||
@@ -173,5 +173,5 @@ object AppUpdate {
|
||||
.intentSender
|
||||
}
|
||||
|
||||
private const val WRITE_NAME = "thoughtsync-update"
|
||||
private const val WRITE_NAME = "inkwell-update"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* The `versionName` of the INSTALLED package, or null when it cannot be read.
|
||||
*
|
||||
* From the package manager rather than from `BuildConfig`: this reports what is
|
||||
* actually on the phone, which is the question both callers are asking — a bug
|
||||
* report reading the foot of Sync, and a server log reading the client header. It
|
||||
* also needs no `buildFeatures.buildConfig`, which this module does not enable.
|
||||
*
|
||||
* Returns null rather than a fallback string, because the two callers want
|
||||
* different ones: the UI wants a localized "unknown" from string resources, the
|
||||
* client header wants the literal the core recognizes. Note 3127 §5 governs both —
|
||||
* with no version tags, the artifact's self-report is the only answer to "which
|
||||
* build is this?", so a missing name must read as missing and never as a plausible
|
||||
* default that nothing can contradict.
|
||||
*/
|
||||
fun Context.installedVersionName(): String? =
|
||||
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
|
||||
+15
-6
@@ -1,8 +1,9 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import com.fabledsword.inkwell.core.setClientAgent
|
||||
|
||||
/**
|
||||
* Opens the shared Rust core once, for the process lifetime.
|
||||
@@ -15,14 +16,14 @@ import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
* removed on uninstall, and never on external media. The core does not guess at
|
||||
* platform paths; Android is the only thing that knows where this is.
|
||||
*/
|
||||
class ThoughtSyncApplication : Application() {
|
||||
class InkwellApplication : Application() {
|
||||
/**
|
||||
* Null only if the store could not be opened — a corrupt or unwritable
|
||||
* database. The UI reports that honestly rather than crashing on first
|
||||
* touch, because a user whose notes won't open needs a message, not a
|
||||
* stack trace.
|
||||
*/
|
||||
var core: ThoughtSync? = null
|
||||
var core: Inkwell? = null
|
||||
private set
|
||||
|
||||
var openFailure: String? = null
|
||||
@@ -30,8 +31,16 @@ class ThoughtSyncApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Introduce this app to any server it links to, before anything can sync.
|
||||
// The core cannot name us — the same crate is compiled into the desktop app,
|
||||
// and it used to announce every phone as `inkwell-desktop` carrying the
|
||||
// core crate's own version. "unknown" rather than a guess when the package
|
||||
// manager will not say (note 3127 §5).
|
||||
setClientAgent("inkwell-android", installedVersionName() ?: "unknown")
|
||||
|
||||
try {
|
||||
val handle = ThoughtSync(filesDir.absolutePath)
|
||||
val handle = Inkwell(filesDir.absolutePath)
|
||||
core = handle
|
||||
Log.i(TAG, "local store ready — ${handle.summary()}")
|
||||
} catch (e: Exception) {
|
||||
@@ -44,6 +53,6 @@ class ThoughtSyncApplication : Application() {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ThoughtSync"
|
||||
const val TAG = "Inkwell"
|
||||
}
|
||||
}
|
||||
+174
-23
@@ -1,7 +1,8 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -11,6 +12,7 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -21,21 +23,28 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||
import com.fabledsword.thoughtsync.ui.BoardSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardUpdate
|
||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||
import com.fabledsword.thoughtsync.ui.SyncScreen
|
||||
import com.fabledsword.thoughtsync.ui.SyncState
|
||||
import com.fabledsword.thoughtsync.ui.SyncViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
||||
import com.fabledsword.thoughtsync.ui.UpdateViewModel
|
||||
import com.fabledsword.thoughtsync.ui.olderThan
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import com.fabledsword.inkwell.ui.AttachmentFiles
|
||||
import com.fabledsword.inkwell.ui.BoardScreen
|
||||
import com.fabledsword.inkwell.ui.BoardSync
|
||||
import com.fabledsword.inkwell.ui.BoardUpdate
|
||||
import com.fabledsword.inkwell.ui.BoardViewModel
|
||||
import com.fabledsword.inkwell.ui.ForegroundTransitions
|
||||
import com.fabledsword.inkwell.ui.InkwellTheme
|
||||
import com.fabledsword.inkwell.ui.LocalAttachmentFiles
|
||||
import com.fabledsword.inkwell.ui.NoteEditorScreen
|
||||
import com.fabledsword.inkwell.ui.ShareSheet
|
||||
import com.fabledsword.inkwell.ui.ShareViewModel
|
||||
import com.fabledsword.inkwell.ui.StoreUnavailableScreen
|
||||
import com.fabledsword.inkwell.ui.SyncScreen
|
||||
import com.fabledsword.inkwell.ui.SyncState
|
||||
import com.fabledsword.inkwell.ui.SyncViewModel
|
||||
import com.fabledsword.inkwell.ui.TagsScreen
|
||||
import com.fabledsword.inkwell.ui.TagsViewModel
|
||||
import com.fabledsword.inkwell.ui.UpdateViewModel
|
||||
import com.fabledsword.inkwell.ui.olderThan
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -51,15 +60,27 @@ class MainActivity : ComponentActivity() {
|
||||
*/
|
||||
private val requestedNote = mutableStateOf<String?>(null)
|
||||
|
||||
/**
|
||||
* Text or files shared into the app from elsewhere, waiting to become a note.
|
||||
*
|
||||
* Same shape and same reason as [requestedNote]: a share that arrives while
|
||||
* the app is already running lands in [onNewIntent], long after the
|
||||
* composition was built, so a piece of state it is already reading is the only
|
||||
* way in. The activity is `singleTop` in the manifest precisely so that this
|
||||
* path exists for an intent another app built.
|
||||
*/
|
||||
private val shared = mutableStateOf<SharedIn?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val app = application as ThoughtSyncApplication
|
||||
val app = application as InkwellApplication
|
||||
requestedNote.value = takeRequestedNote(intent)
|
||||
shared.value = takeShared(intent)
|
||||
|
||||
setContent {
|
||||
ThoughtSyncTheme {
|
||||
InkwellTheme {
|
||||
val core = app.core
|
||||
if (core == null) {
|
||||
// The store never opened. There is no board to show and no
|
||||
@@ -67,7 +88,12 @@ class MainActivity : ComponentActivity() {
|
||||
// than render an empty board that looks like data loss.
|
||||
StoreUnavailableScreen(reason = app.openFailure)
|
||||
} else {
|
||||
App(core, requestedNote)
|
||||
// Application context inside, so holding it for the
|
||||
// composition's life cannot leak this activity.
|
||||
val files = remember(core) { AttachmentFiles(this, core) }
|
||||
CompositionLocalProvider(LocalAttachmentFiles provides files) {
|
||||
App(core, files, requestedNote, shared)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +103,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
requestedNote.value = takeRequestedNote(intent)
|
||||
shared.value = takeShared(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,10 +119,83 @@ class MainActivity : ComponentActivity() {
|
||||
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Read what a share or a text selection brought in, and CONSUME it.
|
||||
*
|
||||
* Consumed for the same reason [takeRequestedNote] is: the activity keeps the
|
||||
* intent it was launched with, so without removing the extras a rotation would
|
||||
* replay the share and mint the same note again, with nothing on screen to
|
||||
* explain where the duplicates were coming from.
|
||||
*/
|
||||
private fun takeShared(intent: Intent?): SharedIn? {
|
||||
val incoming =
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SEND -> SharedIn(intent.takeSendText(), intent.takeStreams())
|
||||
Intent.ACTION_SEND_MULTIPLE -> SharedIn(intent.takeSendText(), intent.takeStreams())
|
||||
Intent.ACTION_PROCESS_TEXT -> SharedIn(intent.takeProcessText(), emptyList())
|
||||
else -> null
|
||||
}
|
||||
return incoming?.takeIf { !it.text.isNullOrBlank() || it.files.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a share brought: words, files, or both — a photo shared from the gallery
|
||||
* often comes with a caption.
|
||||
*
|
||||
* The files are content URIs the sending app granted this one read access to. The
|
||||
* grant lasts while this activity does, which is longer than reading them takes.
|
||||
*/
|
||||
data class SharedIn(
|
||||
val text: String?,
|
||||
val files: List<Uri>,
|
||||
)
|
||||
|
||||
/** The file or files a SEND or SEND_MULTIPLE carried, consumed like the text. */
|
||||
private fun Intent.takeStreams(): List<Uri> {
|
||||
val streams =
|
||||
if (action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
IntentCompat.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java).orEmpty()
|
||||
} else {
|
||||
listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java))
|
||||
}
|
||||
removeExtra(Intent.EXTRA_STREAM)
|
||||
return streams
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared text, with a subject line above it when the sender gave one.
|
||||
*
|
||||
* Sharing a page from a browser sends EXTRA_SUBJECT as the page title and
|
||||
* EXTRA_TEXT as the URL. Keeping both makes the note read as its title, because
|
||||
* the core names a note by its first line — so this is not decoration, it is what
|
||||
* turns a board of identical-looking links into a board you can scan.
|
||||
*
|
||||
* `distinct` because plenty of apps put the same string in both, and a note that
|
||||
* says the URL twice is worse than one that says it once.
|
||||
*/
|
||||
private fun Intent.takeSendText(): String? {
|
||||
val body = getStringExtra(Intent.EXTRA_TEXT)
|
||||
val subject = getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
removeExtra(Intent.EXTRA_TEXT)
|
||||
removeExtra(Intent.EXTRA_SUBJECT)
|
||||
return listOfNotNull(subject, body)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.joinToString("\n")
|
||||
}
|
||||
|
||||
/** The selection from another app's text field, via the selection toolbar. */
|
||||
private fun Intent.takeProcessText(): String? {
|
||||
val text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
|
||||
removeExtra(Intent.EXTRA_PROCESS_TEXT)
|
||||
return text
|
||||
}
|
||||
|
||||
/** Which screen is up. Exactly one at a time. */
|
||||
private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
|
||||
|
||||
/**
|
||||
* The whole app, once the store is open.
|
||||
@@ -104,21 +204,23 @@ private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
* both cover the display completely, so keeping the board's two-column grid
|
||||
* measuring and recomposing underneath one would be pure waste.
|
||||
*
|
||||
* Still no navigation library. Three destinations, each entered from exactly one
|
||||
* Still no navigation library. Four destinations, each entered from exactly one
|
||||
* place and left by back — a nav graph would be ceremony around an enum, and the
|
||||
* state that actually matters (which note is open, whether this device is linked)
|
||||
* already lives in view models.
|
||||
*/
|
||||
@Composable
|
||||
private fun App(
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
files: AttachmentFiles,
|
||||
requestedNote: MutableState<String?>,
|
||||
shared: MutableState<SharedIn?>,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val board: BoardViewModel =
|
||||
viewModel(
|
||||
factory =
|
||||
BoardViewModel.factory(core) {
|
||||
BoardViewModel.factory(core, readFile = files::read) {
|
||||
// Any store write can have moved the next reminder. Called on
|
||||
// the IO dispatcher by the view model, which is where it has to
|
||||
// be — this reads every note carrying a reminder.
|
||||
@@ -135,6 +237,15 @@ private fun App(
|
||||
}
|
||||
}
|
||||
|
||||
// Cleared the same way and for the same reason: without it every later
|
||||
// recomposition would capture the share again as a new note.
|
||||
LaunchedEffect(shared.value) {
|
||||
shared.value?.let {
|
||||
board.captureShared(it.text, it.files)
|
||||
shared.value = null
|
||||
}
|
||||
}
|
||||
|
||||
ReminderAlarms(core)
|
||||
// A pull can rewrite every note the board is holding, so a sync that changed
|
||||
// anything tells it to reload. Wired here, at the one place that owns both.
|
||||
@@ -149,6 +260,18 @@ private fun App(
|
||||
// opens the editor on an unsaved draft, so writing a note and editing one are the
|
||||
// same surface with the same toolbar.
|
||||
var showingSync by rememberSaveable { mutableStateOf(false) }
|
||||
var showingTags by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Tag writes reach the board two ways at once: the drawer lists tags, and the
|
||||
// board may be LOOKING at one that a delete or a merge just removed. Both are
|
||||
// `refreshLabels`, which also leaves a lens whose tag stopped existing.
|
||||
val tags: TagsViewModel =
|
||||
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels))
|
||||
|
||||
// A share landing changes the note's `shared` flag, which the board's card chip
|
||||
// reads, so the board reloads after one.
|
||||
val share: ShareViewModel =
|
||||
viewModel(factory = ShareViewModel.factory(core, onStoreChanged = board::refresh))
|
||||
|
||||
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||
val settings = remember(context) { SyncSettings(context) }
|
||||
@@ -161,6 +284,9 @@ private fun App(
|
||||
val screen =
|
||||
when {
|
||||
showingSync -> Screen.SYNC
|
||||
// Above the editor: tags are reached only from the board's drawer, so
|
||||
// there is never an open note underneath one to go back to.
|
||||
showingTags -> Screen.TAGS
|
||||
editing != null -> Screen.EDITOR
|
||||
else -> Screen.BOARD
|
||||
}
|
||||
@@ -188,6 +314,18 @@ private fun App(
|
||||
onInstallOutcome = update::consumeInstallOutcome,
|
||||
)
|
||||
|
||||
Screen.TAGS ->
|
||||
TagsScreen(
|
||||
state = tags.state,
|
||||
onClose = { showingTags = false },
|
||||
onCreate = tags::create,
|
||||
onRename = tags::rename,
|
||||
onColour = tags::setColour,
|
||||
onDelete = tags::remove,
|
||||
onMerge = tags::merge,
|
||||
onDismissError = tags::dismissError,
|
||||
)
|
||||
|
||||
Screen.EDITOR ->
|
||||
NoteEditorScreen(
|
||||
// Non-null by construction: `screen` is EDITOR only when it is.
|
||||
@@ -196,6 +334,7 @@ private fun App(
|
||||
labels = board.state.labels,
|
||||
saving = board.state.saving,
|
||||
error = board.state.error,
|
||||
onShare = { share.open(editing.id) },
|
||||
// The one seam between the editor and the store. Exhaustive at the
|
||||
// other end, so a new action cannot be added without being handled.
|
||||
onAction = { board.onEditorAction(editing, it) },
|
||||
@@ -218,6 +357,7 @@ private fun App(
|
||||
onDismissError = sync::dismissSyncError,
|
||||
),
|
||||
onOpenSync = { showingSync = true },
|
||||
onManageTags = { showingTags = true },
|
||||
onSearch = board::search,
|
||||
onCompose = board::compose,
|
||||
onToggleItem = board::toggleItem,
|
||||
@@ -244,9 +384,20 @@ private fun App(
|
||||
}
|
||||
}
|
||||
|
||||
// Over whichever screen opened it; a ModalBottomSheet handles its own back.
|
||||
if (share.state.noteId != null) {
|
||||
ShareSheet(
|
||||
state = share.state,
|
||||
onShare = share::share,
|
||||
onUnshare = share::unshare,
|
||||
onDismiss = share::close,
|
||||
)
|
||||
}
|
||||
|
||||
// The sync screen has no back handler of its own, so one lives here. The
|
||||
// editor keeps its own, because it has to save the open note before leaving.
|
||||
BackHandler(enabled = showingSync) { showingSync = false }
|
||||
BackHandler(enabled = showingTags) { showingTags = false }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,7 +414,7 @@ private fun App(
|
||||
* notifications this app would send — is spending it on nothing.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReminderAlarms(core: ThoughtSync) {
|
||||
private fun ReminderAlarms(core: Inkwell) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// Off the main thread: this reads every note that has a reminder, and a phone
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -8,7 +8,7 @@ import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
|
||||
/**
|
||||
* What a due reminder looks like in the shade.
|
||||
@@ -117,5 +117,5 @@ internal object ReminderNotification {
|
||||
)
|
||||
|
||||
private const val CHANNEL = "reminders"
|
||||
private const val TAG = "ThoughtSyncReminders"
|
||||
private const val TAG = "InkwellReminders"
|
||||
}
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -36,7 +36,7 @@ class ReminderReceiver : BroadcastReceiver() {
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
val core = (context.applicationContext as? ThoughtSyncApplication)?.core ?: return
|
||||
val core = (context.applicationContext as? InkwellApplication)?.core ?: return
|
||||
val action = intent.action
|
||||
val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID)
|
||||
val app = context.applicationContext
|
||||
@@ -64,9 +64,9 @@ class ReminderReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_DUE = "com.fabledsword.thoughtsync.REMINDER_DUE"
|
||||
const val ACTION_DONE = "com.fabledsword.thoughtsync.REMINDER_DONE"
|
||||
const val ACTION_SNOOZE = "com.fabledsword.thoughtsync.REMINDER_SNOOZE"
|
||||
private const val TAG = "ThoughtSyncReminders"
|
||||
const val ACTION_DUE = "com.fabledsword.inkwell.REMINDER_DUE"
|
||||
const val ACTION_DONE = "com.fabledsword.inkwell.REMINDER_DONE"
|
||||
const val ACTION_SNOOZE = "com.fabledsword.inkwell.REMINDER_SNOOZE"
|
||||
private const val TAG = "InkwellReminders"
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
@@ -7,8 +7,8 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/**
|
||||
@@ -52,7 +52,7 @@ object Reminders {
|
||||
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
|
||||
|
||||
private const val SNOOZE_MINUTES = 60L
|
||||
private const val TAG = "ThoughtSyncReminders"
|
||||
private const val TAG = "InkwellReminders"
|
||||
|
||||
/**
|
||||
* Announce what is due, then schedule the next one.
|
||||
@@ -63,7 +63,7 @@ object Reminders {
|
||||
*/
|
||||
fun refresh(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
) {
|
||||
ReminderNotification.ensureChannel(context)
|
||||
val notes =
|
||||
@@ -111,7 +111,7 @@ object Reminders {
|
||||
*/
|
||||
fun promptToNotifyDue(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
): Boolean =
|
||||
!Announced(context).askedToNotify &&
|
||||
!NotificationManagerCompat.from(context).areNotificationsEnabled() &&
|
||||
@@ -123,7 +123,7 @@ object Reminders {
|
||||
/** Clear the reminder, as the notification's Done action. */
|
||||
fun complete(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
noteId: String,
|
||||
) {
|
||||
runCatching { core.completeReminder(noteId) }
|
||||
@@ -134,7 +134,7 @@ object Reminders {
|
||||
/** Push the reminder an hour out, as the notification's Snooze action. */
|
||||
fun snooze(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
noteId: String,
|
||||
) {
|
||||
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
|
||||
@@ -237,7 +237,7 @@ private class Announced(
|
||||
fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply()
|
||||
|
||||
private companion object {
|
||||
const val FILE = "thoughtsync-reminders"
|
||||
const val FILE = "inkwell-reminders"
|
||||
const val KEY_SEEN = "announced"
|
||||
const val KEY_PRIMED = "primed"
|
||||
const val KEY_ASKED = "asked_to_notify"
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
@@ -85,8 +85,8 @@ object SyncSchedule {
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
private const val PERIODIC = "thoughtsync-periodic-sync"
|
||||
private const val PUSH = "thoughtsync-push-pending"
|
||||
private const val PERIODIC = "inkwell-periodic-sync"
|
||||
private const val PUSH = "inkwell-push-pending"
|
||||
|
||||
/** WorkManager's own minimum for periodic work. Asking for less gets this. */
|
||||
private const val PERIOD_MINUTES = 15L
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.Context
|
||||
|
||||
@@ -38,7 +38,7 @@ class SyncSettings(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE = "thoughtsync-sync"
|
||||
const val FILE = "inkwell-sync"
|
||||
const val KEY_AUTOMATIC = "automatic"
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
@@ -8,7 +8,7 @@ import androidx.work.WorkerParameters
|
||||
/**
|
||||
* One sync cycle, run by the system rather than by a person.
|
||||
*
|
||||
* WorkManager may start the process to run this, which means [ThoughtSyncApplication.onCreate]
|
||||
* WorkManager may start the process to run this, which means [InkwellApplication.onCreate]
|
||||
* has already opened the store by the time [doWork] is called — the same handle
|
||||
* the UI uses, so there is never a second SQLite connection racing the first.
|
||||
*
|
||||
@@ -30,7 +30,7 @@ class SyncWorker(
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val core = (applicationContext as? ThoughtSyncApplication)?.core
|
||||
val core = (applicationContext as? InkwellApplication)?.core
|
||||
|
||||
// Both of these are "nothing to do", not "something went wrong", so both
|
||||
// report success and let the run retire quietly:
|
||||
@@ -67,6 +67,6 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ThoughtSyncWorker"
|
||||
const val TAG = "InkwellWorker"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -71,7 +71,7 @@ class UpdateReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_INSTALLED = "com.fabledsword.thoughtsync.UPDATE_INSTALLED"
|
||||
private const val TAG = "ThoughtSyncUpdate"
|
||||
const val ACTION_INSTALLED = "com.fabledsword.inkwell.UPDATE_INSTALLED"
|
||||
private const val TAG = "InkwellUpdate"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.media.ExifInterface
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.core.content.FileProvider
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.Attachment
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* A file picked or shared into the app, read and ready to attach — or the reason
|
||||
* it could not be.
|
||||
*/
|
||||
sealed interface PickedFile {
|
||||
// A plain class: a data class would compare `bytes` by identity, and nothing
|
||||
// here compares two picked files anyway.
|
||||
class Ready(
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val bytes: ByteArray,
|
||||
) : PickedFile
|
||||
|
||||
data class Refused(
|
||||
val reason: String,
|
||||
) : PickedFile
|
||||
}
|
||||
|
||||
/** What [AttachmentFiles.opener] made: an intent to start, or why there isn't one. */
|
||||
sealed interface Opener {
|
||||
data class Ready(
|
||||
val intent: Intent,
|
||||
) : Opener
|
||||
|
||||
data class Failed(
|
||||
val message: String,
|
||||
) : Opener
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the app does with attachment FILES, as opposed to attachment rows:
|
||||
* reading a picked file, decoding an image for display, and handing a file to
|
||||
* another app to open.
|
||||
*
|
||||
* Holds the application context, never an Activity, so the board's view model can
|
||||
* keep a reference to [read] without leaking a screen.
|
||||
*/
|
||||
class AttachmentFiles(
|
||||
context: Context,
|
||||
private val core: Inkwell,
|
||||
) {
|
||||
private val app = context.applicationContext
|
||||
|
||||
/**
|
||||
* Decoded images, keyed by hash and size. Sized in bytes against an eighth of
|
||||
* the heap, which is the platform's own guidance for an in-memory image cache.
|
||||
*/
|
||||
private val images =
|
||||
object : LruCache<String, ImageBitmap>(cacheBytes()) {
|
||||
override fun sizeOf(
|
||||
key: String,
|
||||
value: ImageBitmap,
|
||||
): Int = value.width * value.height * BYTES_PER_PIXEL
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a picked or shared file, all of it, with its name and type.
|
||||
*
|
||||
* Blocking; call it off the main thread. Refuses anything over
|
||||
* [MAX_ATTACH_MB] — before reading it when the sender says how big it is — so
|
||||
* a long video shared by mistake is a message rather than an out-of-memory
|
||||
* crash.
|
||||
*/
|
||||
fun read(uri: Uri): PickedFile {
|
||||
val (label, declared) = describe(uri)
|
||||
val overDeclared = (declared ?: 0) > MAX_ATTACH_BYTES
|
||||
val bytes = if (overDeclared) null else readCapped(uri)
|
||||
return when {
|
||||
overDeclared -> tooLarge(label)
|
||||
bytes == null -> PickedFile.Refused(app.getString(R.string.attach_unreadable, label))
|
||||
bytes.size > MAX_ATTACH_BYTES -> tooLarge(label)
|
||||
else -> PickedFile.Ready(label, app.contentResolver.getType(uri) ?: GENERIC_MIME, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The image, decoded no larger than it will be drawn, or null when this device
|
||||
* doesn't hold the file yet or it isn't an image Android can read.
|
||||
*
|
||||
* Blocking; call it off the main thread.
|
||||
*/
|
||||
fun image(
|
||||
attachment: Attachment,
|
||||
maxPx: Int,
|
||||
): ImageBitmap? {
|
||||
val sha = attachment.sha256 ?: return null
|
||||
val key = "$sha@$maxPx"
|
||||
return images.get(key)
|
||||
?: core.blobPath(sha)?.let { decode(it, maxPx) }?.also { images.put(key, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* An intent that hands the file to whatever app opens its type, or the reason
|
||||
* there can't be one.
|
||||
*
|
||||
* 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
|
||||
* spreadsheet. The copy lives in the cache, which the system reclaims, and only
|
||||
* 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 {
|
||||
val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File)
|
||||
if (source == null) return Opener.Failed(app.getString(R.string.attach_not_here))
|
||||
val dir = File(app.cacheDir, "$OPEN_DIR/${attachment.id}")
|
||||
val copy = File(dir, safeName(attachment.filename))
|
||||
return try {
|
||||
dir.mkdirs()
|
||||
if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true)
|
||||
val uri = 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) {
|
||||
Opener.Failed(app.getString(R.string.attach_open_failed, e.message ?: attachment.filename.orEmpty()))
|
||||
}
|
||||
}
|
||||
|
||||
/** The name the sender gives the file and the size it declares, when it does. */
|
||||
private fun describe(uri: Uri): Pair<String, Long?> {
|
||||
val row =
|
||||
try {
|
||||
app.contentResolver.query(uri, COLUMNS, null, null, null)?.use { firstRow(it) }
|
||||
} catch (expected: SecurityException) {
|
||||
// No grant to read it at all; the read that follows says so.
|
||||
null
|
||||
}
|
||||
val name = row?.first?.takeIf { it.isNotBlank() } ?: uri.lastPathSegment ?: FALLBACK_NAME
|
||||
return name to row?.second
|
||||
}
|
||||
|
||||
/**
|
||||
* The file's bytes, at most one past the cap — enough to know it is over without
|
||||
* reading the rest — or null when it can't be read.
|
||||
*/
|
||||
private fun readCapped(uri: Uri): ByteArray? =
|
||||
try {
|
||||
app.contentResolver.openInputStream(uri)?.use { it.readUpTo(MAX_ATTACH_BYTES + 1) }
|
||||
} catch (expected: IOException) {
|
||||
null
|
||||
} catch (expected: SecurityException) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun tooLarge(label: String) =
|
||||
PickedFile.Refused(app.getString(R.string.attach_too_large, label, MAX_ATTACH_MB))
|
||||
|
||||
private companion object {
|
||||
const val BYTES_PER_PIXEL = 4
|
||||
const val CACHE_FRACTION = 8
|
||||
const val OPEN_DIR = "open"
|
||||
const val GENERIC_MIME = "application/octet-stream"
|
||||
const val FALLBACK_NAME = "file"
|
||||
val COLUMNS = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
||||
|
||||
/** How far each EXIF orientation is turned from upright. */
|
||||
val ROTATIONS =
|
||||
mapOf(
|
||||
ExifInterface.ORIENTATION_ROTATE_90 to 90f,
|
||||
ExifInterface.ORIENTATION_ROTATE_180 to 180f,
|
||||
ExifInterface.ORIENTATION_ROTATE_270 to 270f,
|
||||
)
|
||||
|
||||
/** DISPLAY_NAME and SIZE from the first row of a [COLUMNS] query; either may be absent. */
|
||||
fun firstRow(cursor: Cursor): Pair<String?, Long?>? =
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getString(0) to (if (cursor.isNull(1)) null else cursor.getLong(1))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun cacheBytes(): Int =
|
||||
(Runtime.getRuntime().maxMemory() / CACHE_FRACTION)
|
||||
.coerceAtMost(Int.MAX_VALUE.toLong())
|
||||
.toInt()
|
||||
|
||||
fun decode(
|
||||
path: String,
|
||||
maxPx: Int,
|
||||
): ImageBitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
val options =
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, maxPx)
|
||||
}
|
||||
return BitmapFactory.decodeFile(path, options)?.let { upright(it, path).asImageBitmap() }
|
||||
}
|
||||
|
||||
/** Camera photos are stored sideways with a tag saying so; BitmapFactory ignores it. */
|
||||
fun upright(
|
||||
bitmap: Bitmap,
|
||||
path: String,
|
||||
): Bitmap {
|
||||
val orientation =
|
||||
try {
|
||||
ExifInterface(path).getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)
|
||||
} catch (expected: IOException) {
|
||||
// No readable EXIF: draw it as stored.
|
||||
0
|
||||
}
|
||||
val degrees = ROTATIONS[orientation] ?: return bitmap
|
||||
val turn = Matrix().apply { postRotate(degrees) }
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, turn, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's [AttachmentFiles], for the card and the editor. Null in previews and
|
||||
* anywhere it was never provided, where attachments draw as plain file rows.
|
||||
*/
|
||||
val LocalAttachmentFiles = staticCompositionLocalOf<AttachmentFiles?> { null }
|
||||
|
||||
/**
|
||||
* The largest file the phone will attach. Read whole into memory and copied once
|
||||
* into the core, so the cap is about the phone's heap. The server has its own
|
||||
* limit (25 MB by default, `max_attachment_mb`); a file over that one is kept here
|
||||
* and its upload reported as refused.
|
||||
*/
|
||||
const val MAX_ATTACH_MB = 50
|
||||
private const val MAX_ATTACH_BYTES = MAX_ATTACH_MB * 1024 * 1024
|
||||
|
||||
/** `InputStream.readNBytes(int)` is API 33; this app supports 26. */
|
||||
private fun InputStream.readUpTo(limit: Int): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var total = 0
|
||||
while (total < limit) {
|
||||
val read = read(buffer, 0, minOf(buffer.size, limit - total))
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
total += read
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import java.util.Locale
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// The attachment decisions that need no Android: which files draw as pictures, how
|
||||
// far to scale a decode, what to name a copy, how to print a size. Kept apart from
|
||||
// AttachmentFiles so the JVM unit tests can load them without a device.
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* The same rule as `rendersInline` in the web's `notes/attachments.ts`.
|
||||
*/
|
||||
fun rendersInline(mime: String): Boolean {
|
||||
val type = mime.lowercase().substringBefore(';').trim()
|
||||
return type.startsWith("image/") && type != "image/svg+xml"
|
||||
}
|
||||
|
||||
/**
|
||||
* The power-of-two step BitmapFactory decodes at, so the result's longer side is
|
||||
* still at least [maxPx]: never upscaled on screen, never decoded at full camera
|
||||
* resolution for a thumbnail.
|
||||
*/
|
||||
fun sampleSize(
|
||||
width: Int,
|
||||
height: Int,
|
||||
maxPx: Int,
|
||||
): Int {
|
||||
val longest = max(width, height)
|
||||
var sample = 1
|
||||
while (longest / (sample * 2) >= maxPx) sample *= 2
|
||||
return sample
|
||||
}
|
||||
|
||||
/** A name safe to create in the cache: no path separators, never empty. */
|
||||
fun safeName(filename: String?): String {
|
||||
val base =
|
||||
filename
|
||||
?.substringAfterLast('/')
|
||||
?.substringAfterLast('\\')
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
return base.takeIf { it.isNotEmpty() && it != "." && it != ".." } ?: "file"
|
||||
}
|
||||
|
||||
/** "12 KB", "3.4 MB" — the same rounding as `fmtSize` in the web's NoteEditor.vue. */
|
||||
fun sizeLabel(bytes: Long): String =
|
||||
when {
|
||||
bytes < KIB -> "$bytes B"
|
||||
bytes < KIB * KIB -> "${(bytes / KIB).roundToInt()} KB"
|
||||
else -> "%.1f MB".format(Locale.ROOT, bytes / (KIB * KIB))
|
||||
}
|
||||
|
||||
private const val KIB = 1024.0
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.Attachment
|
||||
import com.fabledsword.inkwell.core.LinkPreview
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val ATTACHMENT_RADIUS = 8.dp
|
||||
|
||||
/**
|
||||
* Decoded no larger than this on the card. A board column on a phone is around
|
||||
* 180dp wide, which is about 540px at the common densities, so this is sharp on
|
||||
* every screen the app runs on without decoding a 12-megapixel photo for it.
|
||||
*/
|
||||
private const val CARD_IMAGE_PX = 640
|
||||
|
||||
/** The editor draws images at the sheet's full width. */
|
||||
private const val EDITOR_IMAGE_PX = 1440
|
||||
|
||||
/**
|
||||
* The narrowest an image may draw, as width over height. A tall screenshot drawn
|
||||
* at its own ratio would push the rest of the card off the board, so it is cropped
|
||||
* to 3:4 instead; anything wider than that draws whole.
|
||||
*/
|
||||
private const val MIN_ASPECT = 0.75f
|
||||
|
||||
/** How many file names the card lists before it says "and N more". */
|
||||
private const val CARD_FILE_ROWS = 2
|
||||
|
||||
/**
|
||||
* A note's attachments on its board card: the first image as a picture, then the
|
||||
* other files by name.
|
||||
*
|
||||
* One picture, not a gallery. The card is a glance at the note, and a strip of
|
||||
* thumbnails would make an image note the tallest thing on the board whatever its
|
||||
* words say. The editor shows them all.
|
||||
*/
|
||||
@Composable
|
||||
fun CardAttachments(attachments: List<Attachment>) {
|
||||
val (images, files) = attachments.partition { rendersInline(it.mime) }
|
||||
images.firstOrNull()?.let { first ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
AttachmentImage(attachment = first, maxPx = CARD_IMAGE_PX) {
|
||||
FileRow(attachment = first)
|
||||
}
|
||||
}
|
||||
val rest = images.drop(1) + files
|
||||
rest.take(CARD_FILE_ROWS).forEach { file ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
FileRow(attachment = file)
|
||||
}
|
||||
if (rest.size > CARD_FILE_ROWS) {
|
||||
Text(
|
||||
text = stringResource(R.string.attach_more, rest.size - CARD_FILE_ROWS),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every attachment, in the editor: images at full width, files as rows, each one
|
||||
* opening with the system and each removable.
|
||||
*
|
||||
* A file the server refused says so under it, in its own words — the upload is
|
||||
* not retried, so without this a file that never reaches the other devices would
|
||||
* look exactly like one that did.
|
||||
*/
|
||||
@Composable
|
||||
fun EditorAttachments(
|
||||
attachments: List<Attachment>,
|
||||
readOnly: Boolean,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val open = rememberOpener()
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
attachments.forEach { attachment ->
|
||||
val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) }
|
||||
Box(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
if (rendersInline(attachment.mime)) {
|
||||
AttachmentImage(
|
||||
attachment = attachment,
|
||||
maxPx = EDITOR_IMAGE_PX,
|
||||
onClick = { open(attachment) },
|
||||
) {
|
||||
FileRow(attachment = attachment, onClick = { open(attachment) })
|
||||
}
|
||||
} else {
|
||||
FileRow(attachment = attachment, onClick = { open(attachment) })
|
||||
}
|
||||
if (!readOnly) {
|
||||
IconButton(
|
||||
onClick = remove,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.attach_remove),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
attachment.uploadError?.let { reason ->
|
||||
Text(
|
||||
text = stringResource(R.string.attach_refused, reason),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The note's link previews in the editor, each dismissable.
|
||||
*
|
||||
* Dismissing is the only control: the server made the preview and will not make
|
||||
* it again, so removing one is a decision about this note rather than a refresh.
|
||||
*/
|
||||
@Composable
|
||||
fun EditorPreviews(
|
||||
previews: List<LinkPreview>,
|
||||
readOnly: Boolean,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
previews.forEach { preview ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
) {
|
||||
LinkPreviewCard(preview = preview, compact = true, modifier = Modifier.weight(1f))
|
||||
if (!readOnly) {
|
||||
IconButton(onClick = { onAction(EditorAction.RemovePreview(preview.id)) }) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.preview_remove),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Loading, drawn, or not drawable (not downloaded yet, or not an image Android reads). */
|
||||
private sealed interface ImageLoad {
|
||||
data object Loading : ImageLoad
|
||||
|
||||
data class Ready(
|
||||
val bitmap: ImageBitmap,
|
||||
) : ImageLoad
|
||||
|
||||
data object Missing : ImageLoad
|
||||
}
|
||||
|
||||
/**
|
||||
* An attachment drawn as a picture, decoded off the main thread.
|
||||
*
|
||||
* [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
|
||||
* something is attached instead of a gap.
|
||||
*/
|
||||
@Composable
|
||||
private fun AttachmentImage(
|
||||
attachment: Attachment,
|
||||
maxPx: Int,
|
||||
onClick: (() -> Unit)? = null,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
val files = LocalAttachmentFiles.current
|
||||
val load by produceState<ImageLoad>(ImageLoad.Loading, attachment.sha256, maxPx, files) {
|
||||
val bitmap = files?.let { withContext(Dispatchers.IO) { it.image(attachment, maxPx) } }
|
||||
value = bitmap?.let { ImageLoad.Ready(it) } ?: ImageLoad.Missing
|
||||
}
|
||||
val shape = RoundedCornerShape(ATTACHMENT_RADIUS)
|
||||
when (val state = load) {
|
||||
ImageLoad.Loading ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(4f / 3f)
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
)
|
||||
is ImageLoad.Ready -> {
|
||||
val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT)
|
||||
val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
|
||||
Image(
|
||||
bitmap = state.bitmap,
|
||||
contentDescription = attachment.filename,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(ratio)
|
||||
.clip(shape)
|
||||
.then(tap),
|
||||
)
|
||||
}
|
||||
ImageLoad.Missing -> fallback()
|
||||
}
|
||||
}
|
||||
|
||||
/** A file by name and size, behind a paperclip. Tappable in the editor, not on the card. */
|
||||
@Composable
|
||||
private fun FileRow(
|
||||
attachment: Attachment,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val shape = RoundedCornerShape(ATTACHMENT_RADIUS)
|
||||
val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||
.then(tap)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_attach),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = attachment.filename?.takeIf { it.isNotBlank() } ?: stringResource(R.string.attach_unnamed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
attachment.size?.let { bytes ->
|
||||
Text(
|
||||
text = sizeLabel(bytes),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// Clear of the editor's remove button, which sits over this corner.
|
||||
modifier = Modifier.padding(start = 8.dp, end = if (onClick != null) 32.dp else 0.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an attachment with whatever app handles its type, or say why not.
|
||||
*
|
||||
* The copy out of the blob store runs on IO; the activity starts on the main
|
||||
* thread, from the screen's own context, so the viewer opens over this app rather
|
||||
* than in a task of its own.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberOpener(): (Attachment) -> Unit {
|
||||
val files = LocalAttachmentFiles.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
return { attachment ->
|
||||
if (files != null) {
|
||||
scope.launch {
|
||||
when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) {
|
||||
is Opener.Ready -> start(context, opener)
|
||||
is Opener.Failed -> toast(context, opener.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(
|
||||
context: Context,
|
||||
opener: Opener.Ready,
|
||||
) {
|
||||
try {
|
||||
context.startActivity(opener.intent)
|
||||
} catch (expected: ActivityNotFoundException) {
|
||||
toast(context, context.getString(R.string.attach_no_app))
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(
|
||||
context: Context,
|
||||
message: String,
|
||||
) = Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -33,7 +33,7 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.inkwell.R
|
||||
|
||||
/**
|
||||
* The note's body, as fields and checkboxes rather than as markup.
|
||||
+34
-9
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -62,9 +63,9 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.Label
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -74,6 +75,7 @@ fun BoardScreen(
|
||||
onOpenNote: (Note) -> Unit,
|
||||
sync: BoardSync,
|
||||
onOpenSync: () -> Unit,
|
||||
onManageTags: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||
@@ -138,6 +140,10 @@ fun BoardScreen(
|
||||
onOpenSync()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onManageTags = {
|
||||
onManageTags()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -367,6 +373,7 @@ private fun NavigationDrawer(
|
||||
syncSummary: String?,
|
||||
onOpen: (Destination) -> Unit,
|
||||
onOpenSync: () -> Unit,
|
||||
onManageTags: () -> Unit,
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
@@ -380,18 +387,36 @@ private fun NavigationDrawer(
|
||||
DrawerRow(destination, current, onOpen)
|
||||
}
|
||||
|
||||
if (labels.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
// The header renders even with no tags, unlike the rows below it: the
|
||||
// manage screen is where you go to MAKE the first one, and hiding the
|
||||
// way in until one exists would be a door that appears only once you
|
||||
// are already inside. It is an action ON the section rather than a row
|
||||
// in it, so it cannot be mistaken for one more lens.
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.nav_labels),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
labels.forEach { label ->
|
||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||
IconButton(onClick = onManageTags) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = stringResource(R.string.tags_manage),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
labels.forEach { label ->
|
||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
||||
+127
-25
@@ -1,17 +1,18 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.NoteDraft
|
||||
import com.fabledsword.thoughtsync.core.NoteEdit
|
||||
import com.fabledsword.thoughtsync.core.NoteQuery
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import com.fabledsword.inkwell.core.Label
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
import com.fabledsword.inkwell.core.NoteDraft
|
||||
import com.fabledsword.inkwell.core.NoteEdit
|
||||
import com.fabledsword.inkwell.core.NoteQuery
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -99,7 +100,7 @@ data class BoardState(
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class BoardViewModel(
|
||||
private val core: ThoughtSync,
|
||||
private val core: Inkwell,
|
||||
/**
|
||||
* Called after any write that could have moved a reminder.
|
||||
*
|
||||
@@ -110,6 +111,11 @@ class BoardViewModel(
|
||||
* view model's `onStoreChanged`.
|
||||
*/
|
||||
private val onRemindersChanged: () -> Unit = {},
|
||||
/**
|
||||
* Read a picked or shared file. Blocking; only ever called on the IO dispatcher.
|
||||
* A function rather than a Context for the same reason as the callback above.
|
||||
*/
|
||||
private val readFile: (Uri) -> PickedFile = { PickedFile.Refused(FALLBACK_ERROR) },
|
||||
) : ViewModel() {
|
||||
var state by mutableStateOf(BoardState())
|
||||
private set
|
||||
@@ -123,7 +129,7 @@ class BoardViewModel(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
loadLabels()
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
fun open(destination: Destination) {
|
||||
@@ -162,13 +168,34 @@ class BoardViewModel(
|
||||
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
|
||||
}
|
||||
|
||||
private fun loadLabels() {
|
||||
/**
|
||||
* Reload the drawer's tags, and leave a lens whose tag no longer exists.
|
||||
*
|
||||
* Public because the Tags screen owns operations this board cannot see: a
|
||||
* delete or a merge removes a tag, and the board may be LOOKING at that tag —
|
||||
* `Destination.WithLabel` holds an id, and a query for a deleted one returns
|
||||
* nothing forever. Without the fallback, tidying up tags could strand the board
|
||||
* on a permanently empty lens whose only escape is the drawer.
|
||||
*
|
||||
* A rename needs no fallback: the id survives, and re-listing gives the drawer
|
||||
* the new name. A rename that MERGED is a delete of one of the two, which this
|
||||
* catches by id like any other.
|
||||
*/
|
||||
fun refreshLabels() {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.onSuccess { state = state.copy(labels = it) }
|
||||
.onSuccess { labels ->
|
||||
state = state.copy(labels = labels)
|
||||
val lens = state.destination
|
||||
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
|
||||
open(Destination.Notes)
|
||||
}
|
||||
}
|
||||
// A drawer that cannot list labels is a degraded drawer, not a
|
||||
// broken board — the notes are still there. Failing quietly here
|
||||
// beats an error banner over working content.
|
||||
// beats an error banner over working content. The lens is left
|
||||
// alone in this case on purpose: "I could not read the tags" is not
|
||||
// evidence that this one is gone.
|
||||
.onFailure { state = state.copy(labels = emptyList()) }
|
||||
}
|
||||
}
|
||||
@@ -235,6 +262,39 @@ class BoardViewModel(
|
||||
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture text shared into the app from somewhere else, and open it.
|
||||
*
|
||||
* The note is CREATED here rather than opened as a pre-filled draft, and that
|
||||
* is the whole design of this path. The editor only flushes when its text
|
||||
* differs from the note it was handed (`NoteEditorScreen`'s `flush`), so a
|
||||
* draft arriving already full of the shared text is a draft with nothing to
|
||||
* save — share a link, press back without typing, and it would be gone. A
|
||||
* share has already said "keep this"; making the row first is what honours it.
|
||||
*
|
||||
* Opening the editor afterwards is then free of that risk: the note exists,
|
||||
* back leaves it alone, and adding a line of context is optional rather than
|
||||
* load-bearing.
|
||||
*/
|
||||
fun captureShared(
|
||||
text: String?,
|
||||
files: List<Uri> = emptyList(),
|
||||
) {
|
||||
val content = text?.trim().orEmpty()
|
||||
if (content.isEmpty() && files.isEmpty()) return
|
||||
// A share is a new sitting even if the editor was already open on
|
||||
// something, so the field must be re-keyed onto what arrives. `createFrom
|
||||
// Draft` deliberately does not bump this — it is written for the autosave
|
||||
// case, where re-keying mid-typing would be the bug.
|
||||
draftDismissed = false
|
||||
state = state.copy(editingSession = state.editingSession + 1)
|
||||
// A shared photo arrives with no words, and the note it makes is still a
|
||||
// note: the picture is its content.
|
||||
createFromDraft(content, allowEmpty = files.isNotEmpty()) { created ->
|
||||
if (files.isNotEmpty()) onEditorAction(created, EditorAction.Attach(files))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set when a draft's editor closes, so a create still in flight does not reopen
|
||||
* it. The editor flushes its text and then closes, and the flush is a coroutine —
|
||||
@@ -263,6 +323,10 @@ class BoardViewModel(
|
||||
}
|
||||
EditorAction.DismissError -> dismissError()
|
||||
is EditorAction.SaveText -> createFromDraft(action.body)
|
||||
// Attaching to an empty draft is a note with a file and no words yet, so
|
||||
// it is the one action that may create a note with no text.
|
||||
is EditorAction.Attach ->
|
||||
createFromDraft(draft.body, allowEmpty = true) { created -> onEditorAction(created, action) }
|
||||
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
|
||||
// a note. With autosave at a second, "typed something" is true by the time
|
||||
// anyone reaches the toolbar; before that there is nothing to attribute.
|
||||
@@ -327,7 +391,7 @@ class BoardViewModel(
|
||||
* mutation that lands between a tap and its dispatch cannot redirect the
|
||||
* action at a different note.
|
||||
*
|
||||
* Both suppressions have ONE cause: [EditorAction] has twenty variants, so a
|
||||
* Both suppressions have ONE cause: [EditorAction] has over twenty variants, so a
|
||||
* total function over it is twenty branches and sixty-odd lines no matter how
|
||||
* it is written. Splitting it into sub-dispatchers is the only way to shorten
|
||||
* it, and each of those would need an `else` — which throws away precisely the
|
||||
@@ -387,7 +451,7 @@ class BoardViewModel(
|
||||
}
|
||||
// The drawer lists labels with their note counts, and both
|
||||
// just changed.
|
||||
loadLabels()
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
|
||||
@@ -399,6 +463,35 @@ class BoardViewModel(
|
||||
id,
|
||||
action.rule?.let { NoteEdit.Recurrence(it) } ?: NoteEdit.ClearRecurrence,
|
||||
)
|
||||
|
||||
is EditorAction.Attach -> attach(id, action.uris)
|
||||
is EditorAction.RemoveAttachment -> mutate { it.deleteAttachment(id, action.attachmentId) }
|
||||
is EditorAction.RemovePreview -> mutate { it.deletePreview(id, action.previewId) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read each file and attach it, one at a time.
|
||||
*
|
||||
* A file that can't be read, or is too large, is skipped and named afterwards
|
||||
* rather than failing the rest: sharing four photos where one is a video should
|
||||
* still attach the three.
|
||||
*/
|
||||
private fun attach(
|
||||
id: String,
|
||||
uris: List<Uri>,
|
||||
) {
|
||||
val refused = mutableListOf<String>()
|
||||
mutate(notice = { refused.takeIf { it.isNotEmpty() }?.joinToString("\n") }) { core ->
|
||||
uris.fold(null as Note?) { latest, uri ->
|
||||
when (val file = readFile(uri)) {
|
||||
is PickedFile.Ready -> core.addAttachment(id, file.name, file.mime, file.bytes)
|
||||
is PickedFile.Refused -> {
|
||||
refused += file.reason
|
||||
latest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,13 +518,20 @@ class BoardViewModel(
|
||||
* correct-until-a-moment-ago content, and flashing it empty would be a worse
|
||||
* lie than showing it one frame stale.
|
||||
*
|
||||
* Search results are left alone — they are the answer to a query, not a live
|
||||
* view, and re-running the board query underneath them would replace the hits
|
||||
* with the whole board.
|
||||
* While a search is running the QUERY is re-run rather than the board's
|
||||
* destination — running `load` here would replace the hits with the whole
|
||||
* board, which is why this branch exists at all. It used to keep the existing
|
||||
* list instead, and that was right for a note whose place in the pile changed
|
||||
* and wrong for one that left it: trashing a hit left the card sitting there,
|
||||
* with a snackbar saying it was gone, until the query happened to re-run
|
||||
* (#3111). Re-asking is still the answer to the query, just a current one.
|
||||
*/
|
||||
private fun mutate(
|
||||
closeEditor: Boolean = false,
|
||||
block: (ThoughtSync) -> Note?,
|
||||
// Something to say once the write has landed, shown where an error would be.
|
||||
// Read after `block` has run, so the block can decide it.
|
||||
notice: () -> String? = { null },
|
||||
block: (Inkwell) -> Note?,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(saving = true)
|
||||
@@ -439,10 +539,8 @@ class BoardViewModel(
|
||||
try {
|
||||
val updated = withContext(Dispatchers.IO) { block(core) }
|
||||
val notes =
|
||||
if (state.searching) {
|
||||
state.notes
|
||||
} else {
|
||||
withContext(Dispatchers.IO) { load(state.destination) }
|
||||
withContext(Dispatchers.IO) {
|
||||
if (state.searching) core.searchNotes(state.query) else load(state.destination)
|
||||
}
|
||||
// On IO, not here: re-deriving the alarm reads every note
|
||||
// that carries a reminder, and this line runs on the main
|
||||
@@ -457,7 +555,7 @@ class BoardViewModel(
|
||||
// which is exactly what ticking a checkbox on a card did.
|
||||
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
|
||||
saving = false,
|
||||
error = null,
|
||||
error = notice(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Broad by intent, as elsewhere: the core reports every failure
|
||||
@@ -498,13 +596,14 @@ class BoardViewModel(
|
||||
private const val VIEW_TRASH = "trash"
|
||||
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
readFile: (Uri) -> PickedFile,
|
||||
onRemindersChanged: () -> Unit,
|
||||
): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
BoardViewModel(core, onRemindersChanged) as T
|
||||
BoardViewModel(core, onRemindersChanged, readFile) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,4 +652,7 @@ private fun blankDraft(): Note =
|
||||
previews = emptyList(),
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
permission = "owner",
|
||||
shared = false,
|
||||
sharedBy = null,
|
||||
)
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
// The colour a LABEL wears when nobody picked one for it.
|
||||
//
|
||||
+21
-1
@@ -1,4 +1,6 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Everything the editor can ask for, as one type.
|
||||
@@ -93,4 +95,22 @@ sealed interface EditorAction {
|
||||
data class SetRecurrence(
|
||||
val rule: String?,
|
||||
) : EditorAction
|
||||
|
||||
/**
|
||||
* Attach files picked on this phone or shared into the app.
|
||||
*
|
||||
* URIs rather than bytes: reading them is blocking I/O, and the view model does
|
||||
* it on the IO dispatcher where a failure can still become the error banner.
|
||||
*/
|
||||
data class Attach(
|
||||
val uris: List<Uri>,
|
||||
) : EditorAction
|
||||
|
||||
data class RemoveAttachment(
|
||||
val attachmentId: String,
|
||||
) : EditorAction
|
||||
|
||||
data class RemovePreview(
|
||||
val previewId: String,
|
||||
) : EditorAction
|
||||
}
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.fabledsword.thoughtsync.core.checklistItems
|
||||
import com.fabledsword.thoughtsync.core.checklistRender
|
||||
import com.fabledsword.inkwell.core.checklistItems
|
||||
import com.fabledsword.inkwell.core.checklistRender
|
||||
|
||||
/**
|
||||
* One piece of a note body, as the editor DRAWS it.
|
||||
+47
-15
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.background
|
||||
@@ -39,10 +39,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
|
||||
/**
|
||||
* The editor's action bar, along the top of the surface.
|
||||
@@ -75,12 +76,19 @@ import com.fabledsword.thoughtsync.core.Note
|
||||
fun EditorTopBar(
|
||||
note: Note,
|
||||
readOnly: Boolean,
|
||||
access: NoteAccess,
|
||||
onClose: () -> Unit,
|
||||
onStartChecklist: () -> Unit,
|
||||
onPicker: (Picker) -> Unit,
|
||||
onAttach: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
// Someone else's note (#5175): its text, at `edit`, and this account's own pin
|
||||
// and archive (#5176) 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.
|
||||
val owner = access == NoteAccess.OWNER
|
||||
val dark = isSystemInDarkTheme()
|
||||
TopAppBar(
|
||||
title = {},
|
||||
@@ -96,13 +104,15 @@ fun EditorTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!readOnly) {
|
||||
if (!readOnly && owner) {
|
||||
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
|
||||
Icon(
|
||||
Icons.Filled.Notifications,
|
||||
contentDescription = stringResource(R.string.editor_reminder),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!readOnly) {
|
||||
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
|
||||
// a checklist is text now (M304), so there is no section to be
|
||||
// already-showing and no reason a second list cannot start further
|
||||
@@ -114,13 +124,26 @@ fun EditorTopBar(
|
||||
)
|
||||
}
|
||||
}
|
||||
OverflowMenu(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
onPicker = onPicker,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
onAction = onAction,
|
||||
)
|
||||
// On the bar rather than in the overflow: attaching a photo is something
|
||||
// people look for, and a menu of words is where it would not be found.
|
||||
if (!readOnly && owner) {
|
||||
IconButton(onClick = onAttach) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_attach),
|
||||
contentDescription = stringResource(R.string.editor_attach),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (owner || !note.trashed) {
|
||||
OverflowMenu(
|
||||
note = note,
|
||||
owner = owner,
|
||||
onPicker = onPicker,
|
||||
onShare = onShare,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
},
|
||||
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
|
||||
// Material derives a bar's content colour from its container via
|
||||
@@ -256,8 +279,9 @@ private fun savedLabel(
|
||||
@Composable
|
||||
private fun OverflowMenu(
|
||||
note: Note,
|
||||
readOnly: Boolean,
|
||||
owner: Boolean,
|
||||
onPicker: (Picker) -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
@@ -268,7 +292,7 @@ private fun OverflowMenu(
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.editor_more))
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = close) {
|
||||
if (readOnly) {
|
||||
if (note.trashed) {
|
||||
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
|
||||
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
|
||||
} else {
|
||||
@@ -276,12 +300,20 @@ private fun OverflowMenu(
|
||||
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
|
||||
close,
|
||||
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
||||
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
|
||||
if (owner) {
|
||||
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
|
||||
}
|
||||
// Not on a draft: the server has nothing to share until it is saved.
|
||||
if (owner && note.id != DRAFT_ID) {
|
||||
MenuItem(R.string.editor_share, close, onShare)
|
||||
}
|
||||
MenuItem(
|
||||
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
||||
close,
|
||||
) { onAction(EditorAction.SetArchived(!note.archived)) }
|
||||
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
|
||||
if (owner) {
|
||||
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -36,9 +36,9 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.Label
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
@@ -338,7 +338,7 @@ private fun RecurrenceChips(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetTitle(labelRes: Int) {
|
||||
internal fun SheetTitle(labelRes: Int) {
|
||||
Text(
|
||||
text = stringResource(labelRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.inkwell.R
|
||||
|
||||
// Shared by the board and the editor.
|
||||
//
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.inkwell.core.LinkPreview
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
|
||||
private val PREVIEW_RADIUS = 8.dp
|
||||
|
||||
/**
|
||||
* A URL that is the WHOLE body, whitespace either side allowed.
|
||||
*
|
||||
* Mirrors `LONE_URL_RE` in `NoteCard.vue` deliberately — the two surfaces have to
|
||||
* agree on what counts as "this note is a link", or the same note reads as a card
|
||||
* on one and a paragraph on the other. Someone pasting a link rarely trims it,
|
||||
* which is why the surrounding whitespace is tolerated rather than rejected.
|
||||
*/
|
||||
private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
|
||||
|
||||
/**
|
||||
* The preview for a note that is nothing but a URL, or null.
|
||||
*
|
||||
* Null covers three different situations that all render the same way — the body
|
||||
* is not a lone URL, the server has not unfurled it yet, or it never could. The
|
||||
* card falls back to showing the URL as text in every one of them, so it is never
|
||||
* blank and the link is never unreachable.
|
||||
*
|
||||
* A note written on the phone and not yet synced is permanently in the middle
|
||||
* case: the unfurl happens server-side (`unfurl_queue.py`) and arrives on a later
|
||||
* pull. That is the honest behaviour and it has to look deliberate, which showing
|
||||
* the URL does.
|
||||
*/
|
||||
fun loneUrlPreview(note: Note): LinkPreview? {
|
||||
if (!LONE_URL.matches(note.body)) return null
|
||||
val url = note.body.trim()
|
||||
return note.previews.firstOrNull { it.url == url }
|
||||
}
|
||||
|
||||
/** True when the body is a lone URL, whether or not a preview has arrived for it. */
|
||||
fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body)
|
||||
|
||||
/**
|
||||
* A fetched link preview, in one of two sizes.
|
||||
*
|
||||
* [compact] is a single row — one line of title and the site — for a URL mentioned
|
||||
* *inside* a note that has its own words. The note is the thing; the link is a
|
||||
* footnote to it. Full size is for a note that IS a URL, where the link is the
|
||||
* note and a compact strip would be a card with nothing on it.
|
||||
*
|
||||
* No image, unlike the web's `LinkPreview.vue`. `image_url` is a REMOTE
|
||||
* third-party address, so drawing it would have this app fetch from whatever host
|
||||
* a link happens to point at — on a phone, on possibly metered data, and as the
|
||||
* first image loading anywhere in this client. That is a decision about privacy
|
||||
* and data use rather than a rendering detail, so the text card ships and the
|
||||
* image is left to be asked for (Scribe #3307).
|
||||
*/
|
||||
@Composable
|
||||
fun LinkPreviewCard(
|
||||
preview: LinkPreview,
|
||||
compact: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Every element of the Modifier chain stays on ONE line, which is why the shape
|
||||
// and the two paddings are named first. `standard:chain-method-continuation`
|
||||
// wants a `.` that follows a MULTILINE element glued to its closing paren —
|
||||
// `).padding(…)` — which is unreadable, so the multiline element is avoided
|
||||
// instead (Scribe #3110).
|
||||
val shape = RoundedCornerShape(PREVIEW_RADIUS)
|
||||
val padH = if (compact) 8.dp else 10.dp
|
||||
val padV = if (compact) 6.dp else 8.dp
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||
.padding(horizontal = padH, vertical = padV),
|
||||
) {
|
||||
preview.siteName?.takeIf { it.isNotBlank() }?.let { site ->
|
||||
Text(
|
||||
text = site.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
// The URL stands in for a missing title so the row always says SOMETHING
|
||||
// about where it goes.
|
||||
text = preview.title?.takeIf { it.isNotBlank() } ?: preview.url,
|
||||
style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// First thing to go when there is no room — the compact row is a footnote and
|
||||
// a description would make it the loudest part of the card.
|
||||
if (!compact) {
|
||||
preview.description?.takeIf { it.isNotBlank() }?.let { body ->
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
|
||||
/**
|
||||
* How this account holds a note (#5175), read from the core's `permission`.
|
||||
*
|
||||
* 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
|
||||
* refuses the rest anyway — this only keeps the editor from offering what would be
|
||||
* refused.
|
||||
*/
|
||||
enum class NoteAccess { OWNER, EDIT, VIEW }
|
||||
|
||||
val Note.access: NoteAccess
|
||||
get() =
|
||||
when (permission) {
|
||||
"edit" -> NoteAccess.EDIT
|
||||
"view" -> NoteAccess.VIEW
|
||||
else -> NoteAccess.OWNER
|
||||
}
|
||||
|
||||
/** Its content can't change here: it is in the trash, or shared with us to view. */
|
||||
val Note.readOnlyHere: Boolean
|
||||
get() = trashed || access == NoteAccess.VIEW
|
||||
|
||||
/** The owner's own controls apply: tags, reminder, files, previews, share, trash. */
|
||||
val Note.ownerControlsHere: Boolean
|
||||
get() = !readOnlyHere && access == NoteAccess.OWNER
|
||||
+88
-12
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -38,12 +38,12 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.BodyItem
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.NoteLabel
|
||||
import com.fabledsword.thoughtsync.core.bodyTags
|
||||
import com.fabledsword.thoughtsync.core.checklistItems
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.BodyItem
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
import com.fabledsword.inkwell.core.NoteLabel
|
||||
import com.fabledsword.inkwell.core.bodyTags
|
||||
import com.fabledsword.inkwell.core.checklistItems
|
||||
|
||||
@Composable
|
||||
fun NoteCard(
|
||||
@@ -123,16 +123,48 @@ fun NoteCard(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// A note that is NOTHING but a URL renders as its preview and nothing
|
||||
// else — printing the raw address under a card that already says where it
|
||||
// goes is saying the same thing twice, badly. Until the unfurl lands, or
|
||||
// if it never does, `preview` is null and the body falls through to
|
||||
// NoteBody, which shows the URL. Never a blank card.
|
||||
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
|
||||
|
||||
// Body then checklist, in order — a note can carry both (M13 step 2). The
|
||||
// first line of the body IS the note's name, at the same weight as the rest of
|
||||
// it (M13 steps 3 and 4).
|
||||
if (note.body.isNotBlank()) {
|
||||
NoteBody(note = note, onToggleItem = onToggleItem)
|
||||
if (lonePreview != null) {
|
||||
LinkPreviewCard(preview = lonePreview, compact = false)
|
||||
} else if (note.body.isNotBlank()) {
|
||||
// A note shared with us to view has inert boxes (#5175): ticking one is
|
||||
// changing its text, which is not ours to do.
|
||||
NoteBody(
|
||||
note = note,
|
||||
onToggleItem = if (note.access == NoteAccess.VIEW) INERT_TOGGLE else onToggleItem,
|
||||
)
|
||||
}
|
||||
|
||||
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
|
||||
// under the note's own words rather than stacked on top of them. Putting
|
||||
// them above would set a stranger's headline where the note's first line
|
||||
// should be — the web learned that in M13 and moved them down.
|
||||
if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
note.previews.forEach { preview ->
|
||||
LinkPreviewCard(preview = preview, compact = true)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Under the words and the links, like the web's card: a photo is often what a
|
||||
// note is ABOUT, but its first line is still its name.
|
||||
if (note.attachments.isNotEmpty()) {
|
||||
CardAttachments(attachments = note.attachments)
|
||||
}
|
||||
|
||||
// A note with nothing in it still has to occupy the board legibly — otherwise
|
||||
// it reads as a rendering bug.
|
||||
if (note.body.isBlank()) {
|
||||
if (note.body.isBlank() && note.previews.isEmpty() && note.attachments.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.board_empty_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -145,6 +177,8 @@ fun NoteCard(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReminderChip(instant = at, recurrence = note.recurrence)
|
||||
}
|
||||
|
||||
SharedChip(note)
|
||||
}
|
||||
|
||||
NoteMenu(
|
||||
@@ -195,7 +229,11 @@ private fun NoteMenu(
|
||||
onAction: (EditorAction) -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
// Pin and archive are this account's own on someone else's note too (#5176);
|
||||
// trash is the owner's. A note shared with us never reaches our Trash, so the
|
||||
// trashed set is only ever the owner's.
|
||||
val owner = note.access == NoteAccess.OWNER
|
||||
DropdownMenu(expanded = expanded && (owner || !note.trashed), onDismissRequest = onDismiss) {
|
||||
if (note.trashed) {
|
||||
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
|
||||
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
|
||||
@@ -208,7 +246,9 @@ private fun NoteMenu(
|
||||
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
||||
onDismiss,
|
||||
) { onAction(EditorAction.SetArchived(!note.archived)) }
|
||||
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
|
||||
if (owner) {
|
||||
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,6 +458,42 @@ private fun ReminderChip(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose note this is, when it is not only ours (#5175): "From Robin" on a note someone
|
||||
* shared with us, "Shared" on one of ours that we shared. Nothing on a private note.
|
||||
*/
|
||||
@Composable
|
||||
private fun SharedChip(note: Note) {
|
||||
val text =
|
||||
when {
|
||||
note.access != NoteAccess.OWNER ->
|
||||
stringResource(
|
||||
R.string.share_chip_by,
|
||||
note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone),
|
||||
)
|
||||
note.shared -> stringResource(R.string.share_chip_shared)
|
||||
else -> return
|
||||
}
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint("default")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tint.chipForeground(dark),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||
.background(tint.chipBackground(dark))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** A box that does nothing when tapped: someone else's note, shared to view. */
|
||||
private val INERT_TOGGLE: (Int, Boolean) -> Unit = { _, _ -> }
|
||||
|
||||
private const val MAX_PREVIEW_LINES = 8
|
||||
|
||||
/** How far one long line of a card may wrap before it is cut. */
|
||||
+47
-6
@@ -1,6 +1,8 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,8 +27,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.inkwell.core.Label
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,7 @@ fun NoteEditorScreen(
|
||||
labels: List<Label>,
|
||||
saving: Boolean,
|
||||
error: String?,
|
||||
onShare: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -88,7 +91,13 @@ fun NoteEditorScreen(
|
||||
// A note in the trash is a record, not a document: editing one would silently
|
||||
// resurrect work that was meant to be thrown away. It renders read-only, with
|
||||
// Restore and Delete forever as the only things to do with it.
|
||||
val readOnly = note.trashed
|
||||
//
|
||||
// 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
|
||||
// rows that are the owner's (tags, reminder, files, previews) stay inert.
|
||||
val access = note.access
|
||||
val readOnly = note.readOnlyHere
|
||||
val ownerControls = note.ownerControlsHere
|
||||
|
||||
// Persist the text, if it changed. The baseline check is what makes "open a
|
||||
// note, read it, back out" write nothing at all — without it every glance
|
||||
@@ -132,6 +141,14 @@ fun NoteEditorScreen(
|
||||
|
||||
BackHandler(onBack = leave)
|
||||
|
||||
// The system picker, for any type: a note can carry a PDF as readily as a photo.
|
||||
// Leaving for it stops this screen, so FlushOnStop has already saved the text by
|
||||
// the time the files come back.
|
||||
val pickFiles =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
|
||||
if (uris.isNotEmpty()) onAction(EditorAction.Attach(uris))
|
||||
}
|
||||
|
||||
// Leaving the APP is not closing the editor, so the text has to be saved
|
||||
// without the screen being torn down. Losing a paragraph to an incoming call
|
||||
// is exactly the failure that makes someone stop trusting a notes app.
|
||||
@@ -167,6 +184,7 @@ fun NoteEditorScreen(
|
||||
EditorTopBar(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
access = access,
|
||||
onClose = leave,
|
||||
onStartChecklist = {
|
||||
val (next, id) = blocks.plusTask()
|
||||
@@ -174,6 +192,11 @@ fun NoteEditorScreen(
|
||||
focus = id
|
||||
},
|
||||
onPicker = { picker = it },
|
||||
onAttach = { pickFiles.launch(ANY_TYPE) },
|
||||
onShare = {
|
||||
flush()
|
||||
onShare()
|
||||
},
|
||||
onConfirmDelete = { confirmingDelete = true },
|
||||
onAction = onAction,
|
||||
)
|
||||
@@ -214,6 +237,9 @@ fun NoteEditorScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Whose note this is, and what may be done with it here.
|
||||
SharedByLine(note)
|
||||
|
||||
// A note is its body; its NAME is that body's first line, so there
|
||||
// is nothing separate to type into and nothing rendered bolder than
|
||||
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
|
||||
@@ -232,17 +258,29 @@ fun NoteEditorScreen(
|
||||
// list on screen twice.
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
|
||||
}
|
||||
|
||||
note.remindAt?.let { at ->
|
||||
EditorReminderRow(
|
||||
at = at,
|
||||
recurrence = note.recurrence,
|
||||
readOnly = readOnly,
|
||||
readOnly = !ownerControls,
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
|
||||
if (note.attachments.isNotEmpty()) {
|
||||
EditorAttachments(
|
||||
attachments = note.attachments,
|
||||
readOnly = !ownerControls,
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
|
||||
if (note.previews.isNotEmpty()) {
|
||||
EditorPreviews(previews = note.previews, readOnly = !ownerControls, onAction = onAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,6 +345,9 @@ private fun EditorOverlays(
|
||||
*/
|
||||
private const val AUTOSAVE_IDLE_MS = 1_000L
|
||||
|
||||
/** What the file picker offers: everything. The server takes any type. */
|
||||
private const val ANY_TYPE = "*/*"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
@@ -20,7 +20,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.inkwell.R
|
||||
|
||||
/**
|
||||
* A bordered block, tinted from the same table the notes use.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.content.Context
|
||||
@@ -17,8 +17,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.Reminders
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.Reminders
|
||||
|
||||
/**
|
||||
* Says so when a reminder would not actually reach anyone.
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.Member
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
import com.fabledsword.inkwell.core.NoteShare
|
||||
|
||||
/** "Shared by Robin · view only" over someone else's note; nothing over our own. */
|
||||
@Composable
|
||||
fun SharedByLine(note: Note) {
|
||||
if (note.access == NoteAccess.OWNER) return
|
||||
val who = note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone)
|
||||
val line =
|
||||
if (note.access == NoteAccess.EDIT) {
|
||||
stringResource(R.string.share_by_can_edit, who)
|
||||
} else {
|
||||
stringResource(R.string.share_by_view_only, who)
|
||||
}
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private const val VIEW = "view"
|
||||
private const val EDIT = "edit"
|
||||
|
||||
/**
|
||||
* The Share sheet: who the note is shared with, and adding someone.
|
||||
*
|
||||
* A peer of the web's `ShareDialog.vue`. Each person's permission is a pair of
|
||||
* chips rather than a menu, because there are exactly two and both fit.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShareSheet(
|
||||
state: ShareState,
|
||||
onShare: (userId: String, permission: String) -> Unit,
|
||||
onUnshare: (shareId: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SheetTitle(R.string.share_title)
|
||||
when {
|
||||
state.loading -> Muted(stringResource(R.string.share_loading))
|
||||
state.needsServer -> Muted(stringResource(R.string.share_needs_server))
|
||||
state.loadError != null -> ErrorText(state.loadError)
|
||||
else -> ShareBody(state, onShare, onUnshare)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareBody(
|
||||
state: ShareState,
|
||||
onShare: (userId: String, permission: String) -> Unit,
|
||||
onUnshare: (shareId: String) -> Unit,
|
||||
) {
|
||||
if (state.shares.isEmpty()) {
|
||||
Muted(stringResource(R.string.share_none))
|
||||
}
|
||||
state.shares.forEach { share ->
|
||||
ShareRow(
|
||||
share = share,
|
||||
busy = state.busy,
|
||||
onPermission = { onShare(share.member.id, it) },
|
||||
onRemove = { onUnshare(share.id) },
|
||||
)
|
||||
}
|
||||
if (state.available.isEmpty()) {
|
||||
Muted(stringResource(R.string.share_everyone))
|
||||
} else {
|
||||
AddPerson(state = state, onShare = onShare)
|
||||
}
|
||||
state.error?.let { ErrorText(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareRow(
|
||||
share: NoteShare,
|
||||
busy: Boolean,
|
||||
onPermission: (String) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MemberName(share.member, Modifier.weight(1f))
|
||||
IconButton(onClick = onRemove, enabled = !busy) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.share_remove))
|
||||
}
|
||||
}
|
||||
PermissionChips(selected = share.permission, enabled = !busy, onSelect = onPermission)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddPerson(
|
||||
state: ShareState,
|
||||
onShare: (userId: String, permission: String) -> Unit,
|
||||
) {
|
||||
var pick by remember(state.noteId) { mutableStateOf<String?>(null) }
|
||||
var permission by remember(state.noteId) { mutableStateOf(VIEW) }
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.share_add),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
// Capped, like the tag picker: a sheet that grows past the screen makes its own
|
||||
// scroll fight the sheet's drag.
|
||||
LazyColumn(modifier = Modifier.heightIn(max = MEMBER_LIST_MAX_HEIGHT)) {
|
||||
items(items = state.available, key = { it.id }) { member ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { pick = member.id },
|
||||
) {
|
||||
RadioButton(selected = pick == member.id, onClick = { pick = member.id })
|
||||
MemberName(member, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
PermissionChips(selected = permission, enabled = !state.busy, onSelect = { permission = it })
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(
|
||||
enabled = pick != null && !state.busy,
|
||||
onClick = {
|
||||
pick?.let { onShare(it, permission) }
|
||||
pick = null
|
||||
},
|
||||
) { Text(stringResource(R.string.share_action)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionChips(
|
||||
selected: String,
|
||||
enabled: Boolean,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = selected == VIEW,
|
||||
enabled = enabled,
|
||||
onClick = { onSelect(VIEW) },
|
||||
label = { Text(stringResource(R.string.share_can_view)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected == EDIT,
|
||||
enabled = enabled,
|
||||
onClick = { onSelect(EDIT) },
|
||||
label = { Text(stringResource(R.string.share_can_edit)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberName(
|
||||
member: Member,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = member.displayName.ifBlank { member.email },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (member.displayName.isNotBlank()) {
|
||||
Text(
|
||||
text = member.email,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Muted(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorText(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
private val MEMBER_LIST_MAX_HEIGHT = 240.dp
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.inkwell.core.CoreException
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import com.fabledsword.inkwell.core.Member
|
||||
import com.fabledsword.inkwell.core.NoteShare
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Everything the Share sheet renders from. [noteId] null means the sheet is closed. */
|
||||
data class ShareState(
|
||||
val noteId: String? = null,
|
||||
val loading: Boolean = false,
|
||||
val members: List<Member> = emptyList(),
|
||||
val shares: List<NoteShare> = emptyList(),
|
||||
/** This device has no server, and sharing is between people on one. */
|
||||
val needsServer: Boolean = false,
|
||||
/** Why the sheet couldn't load, when it has a server and still failed. */
|
||||
val loadError: String? = null,
|
||||
/** An in-flight write, for disabling the controls that would race it. */
|
||||
val busy: Boolean = false,
|
||||
val error: String? = null,
|
||||
) {
|
||||
/** The people it isn't shared with yet. */
|
||||
val available: List<Member>
|
||||
get() {
|
||||
val taken = shares.map { it.member.id }.toSet()
|
||||
return members.filterNot { it.id in taken }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sharing a note with other people on the linked server (#5175).
|
||||
*
|
||||
* 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
|
||||
* tokio runtime, not blocking store calls, so no IO dispatcher is needed. The one
|
||||
* thing the core keeps locally is the note's `shared` flag, which is why a write
|
||||
* that landed tells the board to reload: its card chip reads that flag.
|
||||
*/
|
||||
class ShareViewModel(
|
||||
private val core: Inkwell,
|
||||
/** Called after a share was granted, changed or removed. */
|
||||
private val onStoreChanged: () -> Unit,
|
||||
) : ViewModel() {
|
||||
var state by mutableStateOf(ShareState())
|
||||
private set
|
||||
|
||||
fun open(noteId: String) {
|
||||
state = ShareState(noteId = noteId, loading = true)
|
||||
viewModelScope.launch {
|
||||
// Unlinked is not a failure: it is the phone working as intended, so the
|
||||
// sheet says what sharing needs rather than showing an error.
|
||||
state =
|
||||
try {
|
||||
if (!withContext(Dispatchers.IO) { core.syncStatus().linked }) {
|
||||
state.copy(loading = false, needsServer = true)
|
||||
} else {
|
||||
val members = core.shareDirectory()
|
||||
val shares = core.noteShares(noteId)
|
||||
state.copy(loading = false, members = members, shares = shares)
|
||||
}
|
||||
} catch (e: CoreException) {
|
||||
state.copy(loading = false, loadError = e.describeShareFailure())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
state = ShareState()
|
||||
}
|
||||
|
||||
/** Share with someone, or change what they may do. */
|
||||
fun share(
|
||||
userId: String,
|
||||
permission: String,
|
||||
) = write { noteId -> core.shareNote(noteId, userId, permission) }
|
||||
|
||||
fun unshare(shareId: String) = write { noteId -> core.unshareNote(noteId, shareId) }
|
||||
|
||||
private fun write(call: suspend (String) -> List<NoteShare>) {
|
||||
val noteId = state.noteId ?: return
|
||||
state = state.copy(busy = true, error = null)
|
||||
viewModelScope.launch {
|
||||
state =
|
||||
try {
|
||||
val shares = call(noteId)
|
||||
onStoreChanged()
|
||||
state.copy(busy = false, shares = shares)
|
||||
} catch (e: CoreException) {
|
||||
state.copy(busy = false, error = e.describeShareFailure())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: Inkwell,
|
||||
onStoreChanged: () -> Unit,
|
||||
): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = ShareViewModel(core, onStoreChanged) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The core's message is written to be shown ("The server doesn't have this note yet…"). */
|
||||
private fun Throwable.describeShareFailure(): String = message ?: "Something went wrong."
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -27,9 +27,9 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Compatibility
|
||||
import com.fabledsword.thoughtsync.core.RevokeOutcome
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.Compatibility
|
||||
import com.fabledsword.inkwell.core.RevokeOutcome
|
||||
|
||||
// Becoming linked: the probe-then-sign-in flow, and the notices around it.
|
||||
//
|
||||
+34
-4
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -31,18 +31,20 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.UpdateOutcome
|
||||
import com.fabledsword.inkwell.installedVersionName
|
||||
|
||||
/**
|
||||
* Opt-in server pairing.
|
||||
*
|
||||
* The whole screen is written around one idea: **being unlinked is not a
|
||||
* problem.** ThoughtSync is local-first and completely usable having never opened
|
||||
* problem.** Inkwell is local-first and completely usable having never opened
|
||||
* this screen, so the unlinked state leads with "Working offline on this device"
|
||||
* and explains what connecting would ADD, rather than presenting an empty form as
|
||||
* unfinished setup.
|
||||
@@ -120,10 +122,38 @@ fun SyncScreen(
|
||||
onDismissRevokeNotice = onDismissRevokeNotice,
|
||||
)
|
||||
}
|
||||
|
||||
BuildLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
|
||||
* bottom of its rail (#3181).
|
||||
*
|
||||
* Note 3127 §5 is why it is here at all. With version tags gone, an artifact's own
|
||||
* self-report is the only answer to "which build is this?" — so it renders
|
||||
* "unknown" rather than nothing when the name is absent, because a blank line looks
|
||||
* like a layout bug and a plausible default cannot be caught by anything.
|
||||
*
|
||||
* The read itself is `installedVersionName()`, shared with the client header the
|
||||
* app sends its server: one answer to "which build is on this phone", so the line
|
||||
* a person quotes in a bug report and the line in the server's log cannot disagree.
|
||||
*/
|
||||
@Composable
|
||||
private fun BuildLine() {
|
||||
val context = LocalContext.current
|
||||
val unknown = stringResource(R.string.build_unknown)
|
||||
val version = remember(context) { context.installedVersionName() ?: unknown }
|
||||
Text(
|
||||
text = version,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ───────────────────────────────── linked ─────────────────────────────────
|
||||
|
||||
@Composable
|
||||
+13
-9
@@ -1,11 +1,11 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Compatibility
|
||||
import com.fabledsword.thoughtsync.core.SyncOutcome
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.Compatibility
|
||||
import com.fabledsword.inkwell.core.SyncOutcome
|
||||
|
||||
// Turning sync results into sentences.
|
||||
//
|
||||
@@ -32,6 +32,8 @@ fun syncSummary(outcome: SyncOutcome): String {
|
||||
if (sent > 0) parts += stringResource(R.string.sync_summary_sent, sent)
|
||||
if (received > 0) parts += stringResource(R.string.sync_summary_received, received)
|
||||
if (blobs > 0) parts += pluralStringResource(R.plurals.sync_summary_attachments, blobs, blobs)
|
||||
val uploaded = outcome.push.uploaded.toInt()
|
||||
if (uploaded > 0) parts += pluralStringResource(R.plurals.sync_summary_uploaded, uploaded, uploaded)
|
||||
|
||||
val line =
|
||||
if (parts.isEmpty()) {
|
||||
@@ -44,11 +46,13 @@ fun syncSummary(outcome: SyncOutcome): String {
|
||||
// rather than an error — but saying nothing would leave a missing image
|
||||
// looking like data loss.
|
||||
val failed = outcome.pull.blobsFailed.toInt()
|
||||
return if (failed > 0) {
|
||||
line + " " + pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed)
|
||||
} else {
|
||||
line
|
||||
}
|
||||
val notUploaded = outcome.push.uploadFailed.toInt()
|
||||
val notes = mutableListOf(line)
|
||||
if (failed > 0) notes += pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed)
|
||||
// A refused upload is recorded on its attachment and shown in the editor; this
|
||||
// line is what sends someone looking.
|
||||
if (notUploaded > 0) notes += pluralStringResource(R.plurals.sync_summary_upload_failed, notUploaded, notUploaded)
|
||||
return notes.joinToString(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
+10
-10
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -6,12 +6,12 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.thoughtsync.core.Compatibility
|
||||
import com.fabledsword.thoughtsync.core.ProbeResult
|
||||
import com.fabledsword.thoughtsync.core.RevokeOutcome
|
||||
import com.fabledsword.thoughtsync.core.SyncOutcome
|
||||
import com.fabledsword.thoughtsync.core.SyncStatus
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.inkwell.core.Compatibility
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import com.fabledsword.inkwell.core.ProbeResult
|
||||
import com.fabledsword.inkwell.core.RevokeOutcome
|
||||
import com.fabledsword.inkwell.core.SyncOutcome
|
||||
import com.fabledsword.inkwell.core.SyncStatus
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -113,7 +113,7 @@ data class SyncState(
|
||||
* stored cursor next time (Scribe #2736).
|
||||
*/
|
||||
class SyncViewModel(
|
||||
private val core: ThoughtSync,
|
||||
private val core: Inkwell,
|
||||
/**
|
||||
* Called after a sync that changed the store.
|
||||
*
|
||||
@@ -316,7 +316,7 @@ class SyncViewModel(
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
onStoreChanged: () -> Unit,
|
||||
): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@@ -344,7 +344,7 @@ private fun SyncOutcome.changedTheStore(): Boolean =
|
||||
* The message to show for a failure.
|
||||
*
|
||||
* The core writes these for people to read — "notes.example.com responded, but not
|
||||
* with ThoughtSync's configuration" — so they are shown as-is rather than
|
||||
* with Inkwell's configuration" — so they are shown as-is rather than
|
||||
* replaced with a generic string that would throw away the only useful part.
|
||||
*/
|
||||
private fun Exception.describe(): String = message ?: "Something went wrong."
|
||||
@@ -0,0 +1,584 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.Label
|
||||
|
||||
/** The swatch shown beside a tag, and tapped to change its colour. */
|
||||
private val SWATCH = 22.dp
|
||||
|
||||
/** What the row's overflow menu is currently asking about. */
|
||||
private sealed interface TagDialog {
|
||||
data class Rename(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
/** A rename whose new name another tag already holds — see [RenameDialog]. */
|
||||
data class ConfirmMerge(
|
||||
val tag: Label,
|
||||
val into: Label,
|
||||
val name: String,
|
||||
) : TagDialog
|
||||
|
||||
data class Merge(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
data class Delete(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
data class Colour(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag management: list, create, rename, recolour, delete, merge.
|
||||
*
|
||||
* A destination you go to, not a modal. The web's `LabelsModal.vue` is a modal
|
||||
* because a desktop has room to float one over the board; on a phone this is a
|
||||
* place you visit to tidy up, and a full screen is what that is.
|
||||
*
|
||||
* It is also, since the per-note colour picker was removed, the ONLY colour
|
||||
* control in the product. That is why the swatch is a first-class tap target on
|
||||
* every row rather than something behind the overflow menu.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TagsScreen(
|
||||
state: TagsState,
|
||||
onClose: () -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
onRename: (String, String) -> Unit,
|
||||
onColour: (String, String) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onMerge: (String, String) -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
var dialog by remember { mutableStateOf<TagDialog?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.tags_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.tags_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding(),
|
||||
) {
|
||||
// An indeterminate bar rather than blocking the list: a tag write is a
|
||||
// local SQLite call and usually finishes before this is seen at all.
|
||||
if (state.busy) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
// The same banner the board and the editor use. A third way of saying
|
||||
// "that did not work" would be a third thing to keep consistent.
|
||||
state.error?.let { message ->
|
||||
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||
}
|
||||
|
||||
NewTagField(
|
||||
enabled = !state.busy,
|
||||
onCreate = onCreate,
|
||||
)
|
||||
|
||||
if (!state.loading && state.tags.isEmpty()) {
|
||||
EmptyTags()
|
||||
}
|
||||
|
||||
// weight, NOT fillMaxSize: this has siblings above it, and filling the
|
||||
// whole height would measure the list against space the field and the
|
||||
// banner have already taken — pushing the end of the list off-screen.
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(state.tags, key = { it.id }) { tag ->
|
||||
TagRow(
|
||||
tag = tag,
|
||||
enabled = !state.busy,
|
||||
onColour = { dialog = TagDialog.Colour(tag) },
|
||||
onRename = { dialog = TagDialog.Rename(tag) },
|
||||
onMerge = { dialog = TagDialog.Merge(tag) },
|
||||
onDelete = { dialog = TagDialog.Delete(tag) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val open = dialog) {
|
||||
null -> Unit
|
||||
|
||||
is TagDialog.Rename ->
|
||||
RenameDialog(
|
||||
tag = open.tag,
|
||||
others = state.tags,
|
||||
onDismiss = { dialog = null },
|
||||
onRename = { name ->
|
||||
dialog = null
|
||||
onRename(open.tag.id, name)
|
||||
},
|
||||
// Renaming onto a name another tag holds MERGES the two, and that
|
||||
// cannot be undone by repeating it, so the confirmation replaces
|
||||
// this dialog rather than the rename just happening.
|
||||
onWouldMerge = { into, name -> dialog = TagDialog.ConfirmMerge(open.tag, into, name) },
|
||||
)
|
||||
|
||||
is TagDialog.ConfirmMerge ->
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.tags_rename_merges_title, hash(open.into.name)),
|
||||
body = stringResource(R.string.tags_rename_merges_body, hash(open.into.name)),
|
||||
confirm = stringResource(R.string.tags_rename_merges_confirm),
|
||||
onDismiss = { dialog = null },
|
||||
onConfirm = {
|
||||
dialog = null
|
||||
onRename(open.tag.id, open.name)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Merge ->
|
||||
MergeDialog(
|
||||
tag = open.tag,
|
||||
others = state.tags.filter { it.id != open.tag.id },
|
||||
onDismiss = { dialog = null },
|
||||
onMerge = { target ->
|
||||
dialog = null
|
||||
onMerge(open.tag.id, target.id)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Delete ->
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.tags_delete_title, hash(open.tag.name)),
|
||||
// The count is the part that makes the consequence real — "it is on
|
||||
// 40 notes" is a different decision from "delete this tag?". It comes
|
||||
// from the LIST, the only call the core populates a count on.
|
||||
body =
|
||||
open.tag.count
|
||||
?.takeIf { it > 0 }
|
||||
?.let { stringResource(R.string.tags_delete_body_counted, it) }
|
||||
?: stringResource(R.string.tags_delete_body),
|
||||
footnote = stringResource(R.string.tags_delete_from_text),
|
||||
confirm = stringResource(R.string.tags_delete_confirm),
|
||||
onDismiss = { dialog = null },
|
||||
onConfirm = {
|
||||
dialog = null
|
||||
onDelete(open.tag.id)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Colour ->
|
||||
ColourDialog(
|
||||
tag = open.tag,
|
||||
onDismiss = { dialog = null },
|
||||
onPick = { key ->
|
||||
dialog = null
|
||||
onColour(open.tag.id, key)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `#` on the name, everywhere it is spoken about.
|
||||
*
|
||||
* The chips already wear it (`NoteCard.kt`, `EditorChrome.kt`) and it is the
|
||||
* reason these are called tags at all — a dialog that said "Delete grocery?" would
|
||||
* be talking about something else.
|
||||
*/
|
||||
private fun hash(name: String): String = "#$name"
|
||||
|
||||
@Composable
|
||||
private fun NewTagField(
|
||||
enabled: Boolean,
|
||||
onCreate: (String) -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
val submit = {
|
||||
if (text.isNotBlank()) {
|
||||
onCreate(text)
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
hint = R.string.tags_new_hint,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||
)
|
||||
TextButton(onClick = submit, enabled = enabled && text.isNotBlank()) {
|
||||
Text(stringResource(R.string.tags_create))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Said out loud rather than left as a blank screen — and it names the `#` route,
|
||||
* because the operator did not know `#tag` extraction existed at all (Scribe
|
||||
* #2949) and this is the natural place to say so.
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyTags() {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_empty_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.tags_empty_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TagRow(
|
||||
tag: Label,
|
||||
enabled: Boolean,
|
||||
onColour: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onMerge: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = labelTintFor(tag.name, tag.color)
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), CircleShape)
|
||||
.clickable(enabled = enabled, onClick = onColour),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = hash(tag.name),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = tint.tagInk(dark),
|
||||
)
|
||||
Text(
|
||||
text = countLabel(tag.count),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(R.string.tags_actions),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
// Panel.kt's MenuItem, not a bare DropdownMenuItem: every one of
|
||||
// these raises a dialog, and it closes the menu BEFORE acting so the
|
||||
// dialog cannot open underneath a menu still hanging over it.
|
||||
val close = { menuOpen = false }
|
||||
MenuItem(R.string.tags_rename, close, onRename)
|
||||
MenuItem(R.string.tags_merge, close, onMerge)
|
||||
MenuItem(R.string.tags_delete, close, onDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero is its own sentence, not "0 notes".
|
||||
*
|
||||
* A count of null means the core did not populate one — only `list_labels` does —
|
||||
* which is a different thing from a tag with no notes, so it reads as unknown
|
||||
* rather than as empty.
|
||||
*/
|
||||
@Composable
|
||||
private fun countLabel(count: Long?): String =
|
||||
when {
|
||||
count == null -> ""
|
||||
count <= 0L -> stringResource(R.string.tags_count_none)
|
||||
count == 1L -> stringResource(R.string.tags_count_one)
|
||||
else -> stringResource(R.string.tags_count, count.toInt())
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename, with the merge caught before it happens.
|
||||
*
|
||||
* The collision is detected HERE, against the list, rather than from what the
|
||||
* core returns: the merge survivor is whichever tag is older, so it may well be
|
||||
* the one being renamed, and an unchanged id afterwards would prove nothing.
|
||||
* Matching is case-insensitive because the core's is.
|
||||
*/
|
||||
@Composable
|
||||
private fun RenameDialog(
|
||||
tag: Label,
|
||||
others: List<Label>,
|
||||
onDismiss: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
onWouldMerge: (Label, String) -> Unit,
|
||||
) {
|
||||
var text by remember(tag.id) { mutableStateOf(tag.name) }
|
||||
val trimmed = text.trim()
|
||||
val clash =
|
||||
others.firstOrNull { it.id != tag.id && it.name.equals(trimmed, ignoreCase = true) }
|
||||
val submit = {
|
||||
when {
|
||||
trimmed.isEmpty() -> Unit
|
||||
clash != null -> onWouldMerge(clash, trimmed)
|
||||
else -> onRename(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_rename_title, hash(tag.name))) },
|
||||
text = {
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
hint = R.string.tags_new_hint,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = submit, enabled = trimmed.isNotEmpty()) {
|
||||
Text(stringResource(R.string.tags_rename_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge, with the direction stated and the survivor named.
|
||||
*
|
||||
* Unlike a rename — where the OLDER tag survives so that the outcome cannot
|
||||
* depend on which way round it was typed — this one is deliberate, so the
|
||||
* direction the person chooses IS the intent and is honoured. The price of that
|
||||
* is that the direction has to be unmissable, which is why the body names the tag
|
||||
* that stops existing and every row here is the one that survives.
|
||||
*/
|
||||
@Composable
|
||||
private fun MergeDialog(
|
||||
tag: Label,
|
||||
others: List<Label>,
|
||||
onDismiss: () -> Unit,
|
||||
onMerge: (Label) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_merge_title, hash(tag.name))) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_merge_body, hash(tag.name)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (others.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_merge_none),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
others.forEach { other ->
|
||||
val tint = labelTintFor(other.name, other.color)
|
||||
Text(
|
||||
text = hash(other.name),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = tint.tagInk(dark),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onMerge(other) }
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette, and since the per-note picker was removed this is the only place in
|
||||
* the product a colour is chosen.
|
||||
*
|
||||
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is
|
||||
* `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
|
||||
* means "let it pick" rather than "grey", and taking it away would leave no way
|
||||
* back to that.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColourDialog(
|
||||
tag: Label,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_colour_of, hash(tag.name))) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
NOTE_TINTS.forEach { (key, tint) ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onPick(key) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = tint.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
if (key == tag.color) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A destructive confirmation: what it is, what it costs, and one way out. */
|
||||
@Composable
|
||||
private fun ConfirmDialog(
|
||||
title: String,
|
||||
body: String,
|
||||
confirm: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
footnote: String? = null,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = body, style = MaterialTheme.typography.bodyMedium)
|
||||
footnote?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(confirm) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import com.fabledsword.inkwell.core.Label
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Everything the Tags screen renders from.
|
||||
*
|
||||
* [tags] comes from `list_labels`, which is the only call that populates a
|
||||
* `count` — the single-tag returns leave it null by design. So the counts a
|
||||
* confirmation dialog quotes are always the LIST's, never an operation's result.
|
||||
*/
|
||||
data class TagsState(
|
||||
val loading: Boolean = true,
|
||||
val tags: List<Label> = emptyList(),
|
||||
/** An in-flight write, for disabling the controls that would race it. */
|
||||
val busy: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Create, rename, recolour, delete and merge tags.
|
||||
*
|
||||
* A peer of the web's `LabelsModal.vue`, not a reduced companion — the same six
|
||||
* operations over the same core the desktop uses.
|
||||
*
|
||||
* ## Why every write re-lists
|
||||
*
|
||||
* A tag operation changes more than the row it names. A merge deletes one tag and
|
||||
* moves its notes; a delete changes nothing else's count but removes a drawer
|
||||
* lens; a rename can MERGE (see below) and so can make a different row vanish.
|
||||
* Re-listing after each write costs one cheap local SQLite read and removes a
|
||||
* whole class of "the screen thinks there are still two" bugs. Patching the list
|
||||
* in place would mean re-deriving, in Kotlin, rules the core already owns.
|
||||
*
|
||||
* ## Renaming can merge
|
||||
*
|
||||
* `rename_label` folds two tags together when the new name is one another tag
|
||||
* already holds, and the OLDER row survives (Scribe #3324). So it can return a
|
||||
* tag whose id is not the one passed in, and it can make another tag stop
|
||||
* existing. The screen asks first; this view model does not, because a
|
||||
* confirmation belongs to the surface with a person in front of it.
|
||||
*
|
||||
* ## Threading
|
||||
*
|
||||
* All of these are ordinary blocking FFI into SQLite — no async, no network — so
|
||||
* they take [Dispatchers.IO], exactly like the board's calls. Sync happens later:
|
||||
* the core marks the rows dirty and the next sync carries them.
|
||||
*/
|
||||
class TagsViewModel(
|
||||
private val core: Inkwell,
|
||||
/**
|
||||
* Called after any write that landed.
|
||||
*
|
||||
* The board holds its own snapshot of the tag list for the drawer, and its
|
||||
* current destination may BE one of these tags — deleting or merging that one
|
||||
* leaves it looking at a lens that no longer exists. Wiring the two together
|
||||
* explicitly is less magic than a shared event bus and makes the dependency
|
||||
* visible at the construction site, the same way [SyncViewModel] does it.
|
||||
*/
|
||||
private val onStoreChanged: () -> Unit,
|
||||
) : ViewModel() {
|
||||
var state by mutableStateOf(TagsState())
|
||||
private set
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
state =
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.fold(
|
||||
onSuccess = { state.copy(tags = it, loading = false, error = null) },
|
||||
// Unlike the drawer, this screen cannot fail quietly: it is
|
||||
// the only thing on the display, and an empty list here
|
||||
// would read as "you have no tags" rather than "I couldn't
|
||||
// look".
|
||||
onFailure = { state.copy(loading = false, error = it.describeTagFailure()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun create(name: String) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
// Find-or-create in the core: typing a name that exists in another case
|
||||
// attaches the existing tag rather than minting a near-duplicate.
|
||||
write { it.createLabel(trimmed) }
|
||||
}
|
||||
|
||||
fun rename(
|
||||
id: String,
|
||||
name: String,
|
||||
) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
write { it.renameLabel(id, trimmed) }
|
||||
}
|
||||
|
||||
fun setColour(
|
||||
id: String,
|
||||
colour: String,
|
||||
) = write { it.setLabelColor(id, colour) }
|
||||
|
||||
fun remove(id: String) = write { it.removeLabel(id) }
|
||||
|
||||
/**
|
||||
* Fold [sourceId] into [targetId]. The source stops existing.
|
||||
*
|
||||
* Directional and not undone by repeating it — the caller has to have said
|
||||
* which one survives before this runs, because afterwards there is nothing
|
||||
* left to read the direction from.
|
||||
*/
|
||||
fun merge(
|
||||
sourceId: String,
|
||||
targetId: String,
|
||||
) {
|
||||
if (sourceId == targetId) return
|
||||
write { it.mergeLabels(sourceId, targetId) }
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
state = state.copy(error = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one store write, then re-list and tell the board.
|
||||
*
|
||||
* `busy` is cleared in the same assignment that stores the result, so no path
|
||||
* out of here can leave the screen stuck with its controls disabled.
|
||||
*/
|
||||
private fun write(block: (Inkwell) -> Unit) {
|
||||
if (state.busy) return
|
||||
state = state.copy(busy = true, error = null)
|
||||
viewModelScope.launch {
|
||||
val failure =
|
||||
runCatching { withContext(Dispatchers.IO) { block(core) } }
|
||||
.exceptionOrNull()
|
||||
val tags =
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.getOrDefault(state.tags)
|
||||
state =
|
||||
state.copy(
|
||||
tags = tags,
|
||||
busy = false,
|
||||
error = failure?.describeTagFailure(),
|
||||
)
|
||||
// Even a FAILED write can have changed the store — a merge that threw
|
||||
// partway still moved rows — so the board is told either way.
|
||||
onStoreChanged()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: Inkwell,
|
||||
onStoreChanged: () -> Unit,
|
||||
): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = TagsViewModel(core, onStoreChanged) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The core reports problems as one error type carrying a message meant to be
|
||||
* shown, so the message is used when there is one.
|
||||
*/
|
||||
private fun Throwable.describeTagFailure(): String = message ?: "Something went wrong."
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -59,7 +59,7 @@ private val DarkColors =
|
||||
)
|
||||
|
||||
/**
|
||||
* Material 3 in ThoughtSync'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
|
||||
* saw the first build. Dynamic colour is the more Android-native choice and it
|
||||
@@ -72,7 +72,7 @@ private val DarkColors =
|
||||
* If dynamic colour is ever wanted it belongs behind a setting, not as the default.
|
||||
*/
|
||||
@Composable
|
||||
fun ThoughtSyncTheme(
|
||||
fun InkwellTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -25,9 +25,9 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.AppUpdate
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||
import com.fabledsword.inkwell.AppUpdate
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.UpdateOutcome
|
||||
|
||||
/**
|
||||
* Updating the app from the server it is linked to.
|
||||
+7
-7
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -7,10 +7,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.thoughtsync.AppUpdate
|
||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||
import com.fabledsword.thoughtsync.core.ClientUpdate
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.inkwell.AppUpdate
|
||||
import com.fabledsword.inkwell.UpdateOutcome
|
||||
import com.fabledsword.inkwell.core.ClientUpdate
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -61,7 +61,7 @@ data class UpdateState(
|
||||
* secret this app holds across two languages for no gain.
|
||||
*/
|
||||
class UpdateViewModel(
|
||||
private val core: ThoughtSync,
|
||||
private val core: Inkwell,
|
||||
/**
|
||||
* MUST be the application context — it outlives this view model, and holding an
|
||||
* Activity here is the textbook way to leak a window.
|
||||
@@ -207,7 +207,7 @@ class UpdateViewModel(
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
context: Context,
|
||||
): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The Material paperclip ("attach_file"), for the editor's Attach button and the
|
||||
file rows under a note.
|
||||
|
||||
A drawable rather than an Icons.* constant because material-icons-core does not
|
||||
carry it, and the extended set is a multi-megabyte dependency for one glyph.
|
||||
Tinted by whatever draws it, like every other icon in the toolbar.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M16.5,6v11.5c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4V5c0,-1.38 1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5v10.5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V6H10v9.5c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5V5c0,-2.21 -1.79,-4 -4,-4S7,2.79 7,5v12.5c0,3.04 2.46,5.5 5.5,5.5s5.5,-2.46 5.5,-5.5V6h-1.5z" />
|
||||
</vector>
|
||||
@@ -3,10 +3,12 @@
|
||||
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
|
||||
no legacy raster fallback is needed.
|
||||
|
||||
The foreground is the shared maskable asset the web app already ships
|
||||
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone
|
||||
padding adaptive icons require. Reusing it means the phone, the web app and the
|
||||
desktop all wear the same face rather than three near-misses.
|
||||
The foreground is the inkwell mark alone on transparency, inside the 66dp safe
|
||||
circle; the yellow is the background colour. It is rendered by
|
||||
packaging/icons.py from the same drawing as the web and desktop icons, so all
|
||||
three wear one face. Transparent rather than a full-bleed tile because the
|
||||
monochrome layer below reuses it: a themed icon draws its alpha as a silhouette,
|
||||
and an opaque foreground would come out as a solid square.
|
||||
-->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.9 KiB |
@@ -2,6 +2,6 @@
|
||||
<resources>
|
||||
<!-- The product's brand colour, same value the web app's manifest and
|
||||
<meta name="theme-color"> already use. One source of truth for "what
|
||||
colour is ThoughtSync" across the three surfaces. -->
|
||||
colour is Inkwell" across the three surfaces. -->
|
||||
<color name="ic_launcher_background">#F5C518</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">ThoughtSync</string>
|
||||
<string name="app_name">Inkwell</string>
|
||||
|
||||
<!-- Search bar -->
|
||||
<string name="search_hint">Search your notes</string>
|
||||
<string name="search_clear">Clear search</string>
|
||||
<string name="nav_open">Open navigation</string>
|
||||
<string name="nav_labels">Labels</string>
|
||||
<string name="nav_labels">Tags</string>
|
||||
|
||||
<!-- Compose sheet -->
|
||||
<string name="compose_open">New note</string>
|
||||
@@ -42,7 +42,7 @@
|
||||
<string name="editor_body_hint">Take a note…</string>
|
||||
<string name="editor_add_item">Add item</string>
|
||||
<string name="editor_remove_item">Remove item</string>
|
||||
<string name="editor_remove_label">Remove label</string>
|
||||
<string name="editor_remove_label">Remove tag</string>
|
||||
<string name="editor_reminder">Set a reminder</string>
|
||||
<string name="editor_more">More actions</string>
|
||||
<string name="editor_saving">Saving…</string>
|
||||
@@ -52,12 +52,45 @@
|
||||
<string name="editor_done">Done</string>
|
||||
<string name="editor_pin">Pin</string>
|
||||
<string name="editor_unpin">Unpin</string>
|
||||
<string name="editor_labels">Labels…</string>
|
||||
<string name="editor_labels">Tags…</string>
|
||||
<string name="editor_archive">Archive</string>
|
||||
<string name="editor_unarchive">Unarchive</string>
|
||||
<string name="editor_trash">Move to trash</string>
|
||||
<string name="editor_restore">Restore</string>
|
||||
<string name="editor_cancel">Cancel</string>
|
||||
<string name="editor_share">Share…</string>
|
||||
|
||||
<!-- Sharing (#5175). Shares live on the server, so the sheet says so when this
|
||||
phone isn't linked to one. -->
|
||||
<string name="share_title">Share</string>
|
||||
<string name="share_loading">Loading…</string>
|
||||
<string name="share_needs_server">Sharing is between people on a server. Link this phone in Sync to share notes.</string>
|
||||
<string name="share_none">Not shared with anyone yet.</string>
|
||||
<string name="share_everyone">Shared with everyone on this server.</string>
|
||||
<string name="share_add">Add someone</string>
|
||||
<string name="share_action">Share</string>
|
||||
<string name="share_remove">Stop sharing</string>
|
||||
<string name="share_can_view">Can view</string>
|
||||
<string name="share_can_edit">Can edit</string>
|
||||
<string name="share_someone">someone</string>
|
||||
<string name="share_by_view_only">Shared by %1$s · view only</string>
|
||||
<string name="share_by_can_edit">Shared by %1$s · you can edit the text</string>
|
||||
<string name="share_chip_shared">Shared</string>
|
||||
<string name="share_chip_by">From %1$s</string>
|
||||
|
||||
<!-- Attachments. A file is stored on the phone at once and uploaded on a later
|
||||
sync, so nothing here talks about the network. -->
|
||||
<string name="editor_attach">Attach a file</string>
|
||||
<string name="attach_remove">Remove attachment</string>
|
||||
<string name="attach_unnamed">Unnamed file</string>
|
||||
<string name="attach_more">+%1$d more</string>
|
||||
<string name="attach_refused">Not uploaded: %1$s</string>
|
||||
<string name="attach_too_large">%1$s is over %2$d MB, too large to attach from the phone.</string>
|
||||
<string name="attach_unreadable">Couldn\'t read %1$s.</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_open_failed">Couldn\'t open the file: %1$s</string>
|
||||
<string name="preview_remove">Remove link preview</string>
|
||||
|
||||
<!-- Deleting for good is the only thing in the app that cannot be undone, so
|
||||
the copy says exactly that rather than asking "Are you sure?". -->
|
||||
@@ -66,11 +99,61 @@
|
||||
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
|
||||
<string name="editor_delete_forever_confirm">Delete</string>
|
||||
|
||||
<!-- Quick capture from outside the app: the share sheet and the text-selection
|
||||
toolbar. "New note" says what happens; the activity's own label would say
|
||||
who it happens in. -->
|
||||
<string name="capture_process_text">New note</string>
|
||||
|
||||
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
|
||||
schema still says Label, and no string here needs to know that. -->
|
||||
<string name="tags_manage">Manage tags</string>
|
||||
<string name="tags_title">Tags</string>
|
||||
<string name="tags_back">Back</string>
|
||||
<string name="tags_new_hint">New tag</string>
|
||||
<string name="tags_create">Create</string>
|
||||
<string name="tags_count">%1$d notes</string>
|
||||
<string name="tags_count_one">1 note</string>
|
||||
<string name="tags_count_none">No notes yet</string>
|
||||
<string name="tags_empty_title">No tags yet</string>
|
||||
<string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string>
|
||||
<string name="tags_actions">More actions</string>
|
||||
<string name="tags_colour">Colour</string>
|
||||
<string name="tags_colour_of">Colour for %1$s</string>
|
||||
|
||||
<string name="tags_rename">Rename</string>
|
||||
<string name="tags_rename_title">Rename %1$s</string>
|
||||
<string name="tags_rename_confirm">Rename</string>
|
||||
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
|
||||
#3324). A merge cannot be undone by repeating it and is reachable here by
|
||||
a typo, so it says so before it happens — same reasoning as #2116. -->
|
||||
<string name="tags_rename_merges_title">Merge with %1$s?</string>
|
||||
<string name="tags_rename_merges_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
|
||||
<string name="tags_rename_merges_confirm">Merge</string>
|
||||
|
||||
<string name="tags_merge">Merge into…</string>
|
||||
<string name="tags_merge_title">Merge %1$s into…</string>
|
||||
<!-- The survivor is named in the button, not just the title: this is the one
|
||||
operation here that repeating does not undo. -->
|
||||
<string name="tags_merge_body">Every note tagged %1$s will be tagged with the one you pick instead, and %1$s will stop existing. The notes are kept.</string>
|
||||
<string name="tags_merge_none">There is no other tag to merge into.</string>
|
||||
|
||||
<string name="tags_delete">Delete</string>
|
||||
<string name="tags_delete_title">Delete %1$s?</string>
|
||||
<string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string>
|
||||
<string name="tags_delete_body_counted">It is on %1$d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</string>
|
||||
<string name="tags_delete_confirm">Delete</string>
|
||||
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the
|
||||
row cannot un-write the word, so it comes back on that note's next edit —
|
||||
said here rather than left as a surprise. -->
|
||||
<string name="tags_delete_from_text">Tags written as #tag in a note come back when that note is next edited.</string>
|
||||
|
||||
<string name="tags_cancel">Cancel</string>
|
||||
|
||||
<!-- Pickers -->
|
||||
<string name="label_picker_title">Labels</string>
|
||||
<string name="label_new_hint">Type a label and press enter</string>
|
||||
<string name="label_from_tag">from #tag</string>
|
||||
<string name="label_none_body">No labels yet. Type one above, or write a #tag in a note and it becomes one.</string>
|
||||
<string name="label_picker_title">Tags</string>
|
||||
<string name="label_new_hint">Type a tag and press enter</string>
|
||||
<string name="label_from_tag">from the text</string>
|
||||
<string name="label_none_body">No tags yet. Type one above, or write a #tag in a note and it becomes one.</string>
|
||||
<string name="picker_next">Next</string>
|
||||
<string name="picker_set">Set</string>
|
||||
<string name="picker_time_title">Pick a time</string>
|
||||
@@ -114,7 +197,7 @@
|
||||
<string name="reminder_channel">Reminders</string>
|
||||
<string name="reminder_channel_description">Notifies you when a note\'s reminder is due.</string>
|
||||
<string name="reminder_notifications_blocked_title">Reminders can\'t notify you</string>
|
||||
<string name="reminder_notifications_blocked_body">Notifications are turned off for ThoughtSync, so reminders will only show here on the board.</string>
|
||||
<string name="reminder_notifications_blocked_body">Notifications are turned off for Inkwell, so reminders will only show here on the board.</string>
|
||||
<string name="reminder_open_settings">Open settings</string>
|
||||
<string name="reminder_inexact_title">Reminders may arrive late</string>
|
||||
<string name="reminder_inexact_body">Without permission for exact alarms, Android delivers reminders when it next wakes the phone — usually within a few minutes, sometimes longer.</string>
|
||||
@@ -131,7 +214,7 @@
|
||||
<string name="update_later">Later</string>
|
||||
<string name="update_failed_title">The update didn\'t install</string>
|
||||
<string name="update_permission_title">Android needs your permission</string>
|
||||
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
|
||||
<string name="update_permission_body">Inkwell has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
|
||||
<string name="update_permission_action">Allow installing</string>
|
||||
<string name="update_needs_server">App updates come from a server you connect. Until then, install new builds yourself.</string>
|
||||
<string name="sync_footer">Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.</string>
|
||||
@@ -146,14 +229,14 @@
|
||||
|
||||
<!-- Unlinked -->
|
||||
<string name="sync_offline_title">Working offline on this device</string>
|
||||
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect a ThoughtSync server if you want them to reach your other devices.</string>
|
||||
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect an Inkwell server if you want them to reach your other devices.</string>
|
||||
<string name="sync_address_label">Server address</string>
|
||||
<string name="sync_address_hint">notes.example.com</string>
|
||||
<string name="sync_address_help">Uses https unless you type http:// yourself.</string>
|
||||
<string name="sync_check">Check</string>
|
||||
<string name="sync_probe_failed">Couldn\'t reach that server</string>
|
||||
<string name="sync_link_failed">Couldn\'t connect</string>
|
||||
<string name="sync_server_generic">ThoughtSync server</string>
|
||||
<string name="sync_server_generic">Inkwell server</string>
|
||||
<string name="sync_server_version">v%1$s</string>
|
||||
<string name="sync_compat_ok">Fully compatible.</string>
|
||||
<string name="sync_compat_degraded">Compatible, but these features aren\'t available on this server: %1$s.</string>
|
||||
@@ -197,7 +280,20 @@
|
||||
<item quantity="one">%d attachment didn\'t download — it\'ll retry on the next sync.</item>
|
||||
<item quantity="other">%d attachments didn\'t download — they\'ll retry on the next sync.</item>
|
||||
</plurals>
|
||||
<plurals name="sync_summary_uploaded">
|
||||
<item quantity="one">uploaded %d file</item>
|
||||
<item quantity="other">uploaded %d files</item>
|
||||
</plurals>
|
||||
<plurals name="sync_summary_upload_failed">
|
||||
<item quantity="one">%d file didn\'t upload. If the server refused it, its note says why; otherwise it\'ll retry.</item>
|
||||
<item quantity="other">%d files didn\'t upload. Any the server refused say why on their note; the rest will retry.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Errors -->
|
||||
<string name="error_dismiss">Dismiss</string>
|
||||
|
||||
<!-- The build, at the foot of Sync. Never blank: an APK with no versionName is
|
||||
a real state (a bare `gradlew assembleDebug` with no override) and saying
|
||||
so is better than an empty line that reads as a layout bug. -->
|
||||
<string name="build_unknown">unknown</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
truth in XML — the same reason the desktop reads its live theme rather
|
||||
than hardcoding a window colour.
|
||||
-->
|
||||
<style name="Theme.ThoughtSync" parent="android:Theme.Material.NoActionBar" />
|
||||
<style name="Theme.Inkwell" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
What the FileProvider may hand to another app: ONLY the copies made to open an
|
||||
attachment (AttachmentFiles.open), never the store or the blob directory itself.
|
||||
A viewer gets read access to the one file it was asked to show.
|
||||
-->
|
||||
<paths>
|
||||
<cache-path name="open" path="open/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AttachmentRulesTest {
|
||||
@Test
|
||||
fun `raster images draw inline and SVG never does`() {
|
||||
assertTrue(rendersInline("image/png"))
|
||||
assertTrue(rendersInline("image/jpeg"))
|
||||
assertTrue(rendersInline("IMAGE/WEBP; charset=binary"))
|
||||
assertFalse(rendersInline("image/svg+xml"))
|
||||
assertFalse(rendersInline("Image/SVG+XML"))
|
||||
assertFalse(rendersInline("application/pdf"))
|
||||
assertFalse(rendersInline(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a decode is scaled down but never below the size it is drawn at`() {
|
||||
assertEquals(1, sampleSize(width = 600, height = 400, maxPx = 640))
|
||||
assertEquals(1, sampleSize(width = 1279, height = 900, maxPx = 640))
|
||||
assertEquals(2, sampleSize(width = 1280, height = 900, maxPx = 640))
|
||||
// A 12-megapixel portrait photo for a card: the longer side decides.
|
||||
assertEquals(4, sampleSize(width = 3024, height = 4032, maxPx = 640))
|
||||
assertTrue(4032 / sampleSize(3024, 4032, 640) >= 640)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cache copy's name cannot leave its directory and is never empty`() {
|
||||
assertEquals("receipt.pdf", safeName("receipt.pdf"))
|
||||
assertEquals("passwd", safeName("../../etc/passwd"))
|
||||
assertEquals("evil.txt", safeName("C:\\temp\\evil.txt"))
|
||||
assertEquals("file", safeName(".."))
|
||||
assertEquals("file", safeName(" "))
|
||||
assertEquals("file", safeName(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sizes print like the web prints them`() {
|
||||
assertEquals("512 B", sizeLabel(512))
|
||||
assertEquals("2 KB", sizeLabel(1536))
|
||||
assertEquals("3.5 MB", sizeLabel(3_670_016))
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
@@ -7,12 +7,10 @@ import org.junit.Test
|
||||
/**
|
||||
* Pins the derived-colour rule against `frontend/src/notes/colors.ts`.
|
||||
*
|
||||
* These are not tests of Kotlin — they are the ONE mechanical guard the mirrored pair
|
||||
* has. The web side is TypeScript with no test runner (its CI lane is `vue-tsc
|
||||
* --noEmit` and nothing else), so if these values drift, nothing on that surface will
|
||||
* say so and a tag will simply be a different colour on the phone than in the browser.
|
||||
* The same names and hashes are written into colors.ts as a comment; changing either
|
||||
* side means changing both and re-checking here.
|
||||
* These are not tests of Kotlin — they pin the pair. The web runs the same values
|
||||
* from `core/testdata/grammar.json` (`tint`) in `notes/grammar.test.ts`; this file
|
||||
* 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.
|
||||
*
|
||||
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and
|
||||
* the resolution order that chose between a picked colour, a tag's and a generated one
|
||||
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "thoughtsync-uniffi-bindgen"
|
||||
name = "inkwell-uniffi-bindgen"
|
||||
version = "0.1.0"
|
||||
description = "Generates the Kotlin bindings for thoughtsync-ffi"
|
||||
description = "Generates the Kotlin bindings for inkwell-ffi"
|
||||
authors = ["bvandeusen"]
|
||||
edition = "2021"
|
||||
|
||||
# A crate whose ONLY dependency is uniffi itself.
|
||||
#
|
||||
# This started life as a `[[bin]]` inside thoughtsync-ffi, which failed: building
|
||||
# This started life as a `[[bin]]` inside inkwell-ffi, which failed: building
|
||||
# it compiled that crate and therefore the core, reqwest, native-tls and
|
||||
# openssl-sys — for the HOST. The vendored-OpenSSL block in core/Cargo.toml is
|
||||
# scoped to `cfg(target_os = "android")`, so a host build looks for a system
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! Invoked by Gradle (see android/app/build.gradle.kts) as:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --locked -p thoughtsync-uniffi-bindgen -- \
|
||||
//! generate --library <path/to/libthoughtsync_ffi.so> \
|
||||
//! cargo run --locked -p inkwell-uniffi-bindgen -- \
|
||||
//! generate --library <path/to/libinkwell_ffi.so> \
|
||||
//! --language kotlin --out-dir <build/generated/uniffi>
|
||||
//! ```
|
||||
//!
|
||||
|
||||
@@ -74,7 +74,7 @@ exceptions:
|
||||
# right and still applies.
|
||||
excludes:
|
||||
- "**/ui/**"
|
||||
- "**/ThoughtSyncApplication.kt"
|
||||
- "**/InkwellApplication.kt"
|
||||
- "**/SyncWorker.kt"
|
||||
- "**/ReminderReceiver.kt"
|
||||
- "**/AppUpdate.kt"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "thoughtsync-ffi"
|
||||
name = "inkwell-ffi"
|
||||
version = "0.1.0"
|
||||
description = "uniffi bindings exposing thoughtsync-core to the native Android client"
|
||||
description = "uniffi bindings exposing inkwell-core to the native Android client"
|
||||
authors = ["bvandeusen"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -10,10 +10,10 @@ edition = "2021"
|
||||
# bindgen binary below — and this crate's own tests — can use the crate normally;
|
||||
# a cdylib-only crate is unusable from Rust.
|
||||
crate-type = ["cdylib", "lib"]
|
||||
name = "thoughtsync_ffi"
|
||||
name = "inkwell_ffi"
|
||||
|
||||
[dependencies]
|
||||
thoughtsync-core = { path = "../../core" }
|
||||
inkwell-core = { path = "../../core" }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
|
||||
+322
-25
@@ -1,4 +1,4 @@
|
||||
//! uniffi bindings: `thoughtsync-core` as seen from Kotlin.
|
||||
//! uniffi bindings: `inkwell-core` as seen from Kotlin.
|
||||
//!
|
||||
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
|
||||
//! — a thin shim over the shared core, holding no logic of its own. If something here
|
||||
@@ -7,7 +7,7 @@
|
||||
//!
|
||||
//! ## Shape
|
||||
//!
|
||||
//! One `ThoughtSync` object holds the store and the blob directory, mirroring how
|
||||
//! One `Inkwell` object holds the store and the blob directory, mirroring how
|
||||
//! Tauri manages them as app state. Kotlin constructs it once, keeps it for the
|
||||
//! process lifetime, and calls methods on it.
|
||||
//!
|
||||
@@ -38,13 +38,13 @@ pub mod models;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use thoughtsync_core::local::{self, Db};
|
||||
use thoughtsync_core::sync::blobs::BlobStore;
|
||||
use thoughtsync_core::sync::{client, compat, engine, push, state};
|
||||
use inkwell_core::local::{self, Db};
|
||||
use inkwell_core::sync::blobs::BlobStore;
|
||||
use inkwell_core::sync::{client, compat, engine, push, sharing, state};
|
||||
|
||||
use models::{
|
||||
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit,
|
||||
NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
|
||||
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Member, Note, NoteDraft,
|
||||
NoteEdit, NoteQuery, NoteShare, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
@@ -108,30 +108,30 @@ impl CoreError {
|
||||
/// behind its mutex, the blob store being a path — which is what lets uniffi share
|
||||
/// one instance across coroutines.
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct ThoughtSync {
|
||||
pub struct Inkwell {
|
||||
db: Db,
|
||||
blobs: BlobStore,
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
impl ThoughtSync {
|
||||
impl Inkwell {
|
||||
/// Open (creating on first run) the store under `data_dir`, and the attachment
|
||||
/// directory beside it.
|
||||
///
|
||||
/// `data_dir` comes from Kotlin because only Android knows where its app-private
|
||||
/// storage is; the core must not guess at a platform path. The layout inside is
|
||||
/// the core's business and matches the desktop's exactly — `thoughtsync.db` and
|
||||
/// the core's business and matches the desktop's exactly — `inkwell.db` and
|
||||
/// `blobs/` — so a store is readable by any client that opens it.
|
||||
#[uniffi::constructor]
|
||||
pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> {
|
||||
let dir = PathBuf::from(data_dir);
|
||||
std::fs::create_dir_all(&dir).map_err(CoreError::store)?;
|
||||
|
||||
let db = local::open(&dir.join("thoughtsync.db")).map_err(CoreError::store)?;
|
||||
let db = local::open(&dir.join("inkwell.db")).map_err(CoreError::store)?;
|
||||
log::info!("local store ready — {}", local::summary(&db));
|
||||
|
||||
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?;
|
||||
Ok(Arc::new(ThoughtSync { db, blobs }))
|
||||
Ok(Arc::new(Inkwell { db, blobs }))
|
||||
}
|
||||
|
||||
/// A one-line count summary, for the boot log.
|
||||
@@ -333,6 +333,115 @@ impl ThoughtSync {
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Rename a label. Every note carrying it follows, because notes reference it
|
||||
/// by id and never by name.
|
||||
///
|
||||
/// Renaming onto a name another tag already holds MERGES the two, and the OLDER
|
||||
/// row is the survivor — it keeps its id and colour and takes the new spelling.
|
||||
/// Matching is case-insensitive, like `find_or_create_label`.
|
||||
///
|
||||
/// So this call can return a label whose id is NOT the one passed in, and it can
|
||||
/// make another label stop existing. A UI over it should say so before calling:
|
||||
/// the merge cannot be undone by repeating it, and here it is reachable by a
|
||||
/// typo in a text field. The web asks first (`stores/labels.ts`); this binding
|
||||
/// deliberately does not, because a confirmation belongs to the surface that has
|
||||
/// a person in front of it, not to the store.
|
||||
///
|
||||
/// `store::rename_label` and the server's PATCH implement the same rule, so the
|
||||
/// phone, the desktop and the web agree on which row survives.
|
||||
pub fn rename_label(&self, id: String, name: String) -> Result<Label, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::rename_label(&conn, &id, &name)
|
||||
.map(Label::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Recolour a label.
|
||||
///
|
||||
/// `color` is a palette KEY from the shared vocabulary (`NoteTint.kt` on this
|
||||
/// side), not a hex value — the point of the shared palette is that a colour
|
||||
/// picked on the phone resolves to the same swatch on the web and the desktop,
|
||||
/// which a literal colour could not promise across themes.
|
||||
pub fn set_label_color(&self, id: String, color: String) -> Result<Label, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::set_label_color(&conn, &id, &color)
|
||||
.map(Label::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Delete a label. The notes that carried it are NOT deleted — they simply stop
|
||||
/// carrying it, which is the thing a confirmation dialog has to say out loud.
|
||||
///
|
||||
/// A `#tag` in a body will re-derive the label on the next edit of that note.
|
||||
/// That is correct rather than a leak: the text mandates it, and deleting the
|
||||
/// row cannot un-write the word.
|
||||
pub fn remove_label(&self, id: String) -> Result<(), CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::remove_label(&conn, &id).map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Fold `source` into `target` and return the survivor.
|
||||
///
|
||||
/// DIRECTIONAL and NOT reversible by repeating it: source stops existing. Any
|
||||
/// UI over this has to name the survivor before it runs, because afterwards
|
||||
/// there is nothing left to read the direction from.
|
||||
pub fn merge_labels(&self, source_id: String, target_id: String) -> Result<Label, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::merge_labels(&conn, &source_id, &target_id)
|
||||
.map(Label::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
// ────────────────────────── attachments and previews ──────────────────────────
|
||||
|
||||
/// Attach a file. The bytes go into the blob store now and up to the server on
|
||||
/// the next sync that can reach one, so attaching works with no network.
|
||||
///
|
||||
/// The bytes cross the FFI as one buffer. Kotlin caps what it reads before
|
||||
/// calling, because this copies the whole file into Rust's memory once.
|
||||
pub fn add_attachment(
|
||||
&self,
|
||||
note_id: String,
|
||||
filename: String,
|
||||
mime: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::add_attachment(&conn, &self.blobs, ¬e_id, &filename, &mime, &bytes)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
pub fn delete_attachment(
|
||||
&self,
|
||||
note_id: String,
|
||||
attachment_id: String,
|
||||
) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::delete_attachment(&conn, ¬e_id, &attachment_id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
pub fn delete_preview(&self, note_id: String, preview_id: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::delete_preview(&conn, ¬e_id, &preview_id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Where this device holds an attachment's bytes, or None when it doesn't yet
|
||||
/// (still downloading, or the download failed).
|
||||
///
|
||||
/// A path rather than the bytes, so Kotlin can decode an image at the size it
|
||||
/// will be drawn instead of pulling the full file across the FFI for a thumbnail.
|
||||
pub fn blob_path(&self, sha256: String) -> Option<String> {
|
||||
self.blobs
|
||||
.path(&sha256)
|
||||
.filter(|p| p.is_file())
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
// ─────────────────────────────── sync ────────────────────────────────
|
||||
|
||||
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
||||
@@ -354,7 +463,7 @@ impl ThoughtSync {
|
||||
/// functions. Split into its own impl block so the runtime attribute — and the fact
|
||||
/// that everything in here touches the network — is visible at a glance.
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
impl ThoughtSync {
|
||||
impl Inkwell {
|
||||
/// Ask a server who it is, without committing to anything. Called as the user
|
||||
/// finishes typing an address, so they see what answered before handing over
|
||||
/// credentials.
|
||||
@@ -466,7 +575,7 @@ impl ThoughtSync {
|
||||
///
|
||||
/// Takes the destination rather than choosing one: only Android knows a
|
||||
/// directory its own package installer can read from, and the core has no
|
||||
/// business guessing at platform paths — the same reason `ThoughtSync::new`
|
||||
/// business guessing at platform paths — the same reason `Inkwell::new`
|
||||
/// takes a data dir.
|
||||
pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> {
|
||||
let (base_url, token) = self.credentials()?;
|
||||
@@ -497,6 +606,55 @@ impl ThoughtSync {
|
||||
.map(SyncOutcome::from)
|
||||
.map_err(CoreError::network)
|
||||
}
|
||||
|
||||
// ────────────────────────────── sharing ──────────────────────────────
|
||||
//
|
||||
// The Share dialog asks the server directly (#5175). Unlinked, each answers
|
||||
// `NotLinked`, which the dialog turns into "sharing needs a server".
|
||||
|
||||
/// Everyone on the instance a note can be shared with.
|
||||
pub async fn share_directory(&self) -> Result<Vec<Member>, CoreError> {
|
||||
self.credentials()?;
|
||||
let members = sharing::directory(&self.db)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(members.into_iter().map(Member::from).collect())
|
||||
}
|
||||
|
||||
/// Who this note is shared with.
|
||||
pub async fn note_shares(&self, note_id: String) -> Result<Vec<NoteShare>, CoreError> {
|
||||
self.credentials()?;
|
||||
let shares = sharing::list(&self.db, ¬e_id)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||
}
|
||||
|
||||
/// Share with one member at "view" or "edit", or change their permission.
|
||||
pub async fn share_note(
|
||||
&self,
|
||||
note_id: String,
|
||||
user_id: String,
|
||||
permission: String,
|
||||
) -> Result<Vec<NoteShare>, CoreError> {
|
||||
self.credentials()?;
|
||||
let shares = sharing::share(&self.db, ¬e_id, &user_id, &permission)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||
}
|
||||
|
||||
pub async fn unshare_note(
|
||||
&self,
|
||||
note_id: String,
|
||||
share_id: String,
|
||||
) -> Result<Vec<NoteShare>, CoreError> {
|
||||
self.credentials()?;
|
||||
let shares = sharing::unshare(&self.db, ¬e_id, &share_id)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ── checklist text, as pure functions ───────────────────────────────────────
|
||||
@@ -520,6 +678,20 @@ pub fn checklist_render(text: String, checked: bool) -> String {
|
||||
local::derive::render_item(&text, checked)
|
||||
}
|
||||
|
||||
/// Tell the core which app it is running inside, and which build of it.
|
||||
///
|
||||
/// Android has to say so because the core cannot: the same crate is compiled into
|
||||
/// the desktop app, and it used to announce every phone in the field as
|
||||
/// `inkwell-desktop` carrying the CORE crate's version — a number no build
|
||||
/// stamps and nobody has seen. The honest value is the installed package's own
|
||||
/// `versionName`, which is what Kotlin passes here.
|
||||
///
|
||||
/// Called once from `InkwellApplication.onCreate`, before anything can sync.
|
||||
#[uniffi::export]
|
||||
pub fn set_client_agent(name: String, version: String) {
|
||||
compat::set_client_agent(&name, &version);
|
||||
}
|
||||
|
||||
/// Every checklist item in a body, with the line each one sits on — so a renderer
|
||||
/// walking the body line by line knows which lines are boxes and what is in them.
|
||||
#[uniffi::export]
|
||||
@@ -547,7 +719,7 @@ pub fn body_tags(body: String) -> Vec<BodyTag> {
|
||||
|
||||
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
||||
/// block names, so these stay Rust-side.
|
||||
impl ThoughtSync {
|
||||
impl Inkwell {
|
||||
/// Apply a `{text}` or `{checked}` patch to one checklist item.
|
||||
///
|
||||
/// The two public setters differ only in the key they write, and the lock +
|
||||
@@ -607,7 +779,7 @@ mod tests {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"thoughtsync-ffi-{}-{}",
|
||||
"inkwell-ffi-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
@@ -628,7 +800,7 @@ mod tests {
|
||||
#[test]
|
||||
fn creates_a_store_and_round_trips_a_note() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(draft("Groceries\nmilk"))
|
||||
@@ -650,7 +822,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_note_is_named_by_its_first_line() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(draft("just a thought"))
|
||||
@@ -665,7 +837,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_note_with_only_items_is_named_by_its_first_item() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(NoteDraft {
|
||||
@@ -684,7 +856,7 @@ mod tests {
|
||||
#[test]
|
||||
fn syncing_unlinked_reports_not_linked() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let status = app.sync_status().expect("status should read");
|
||||
assert!(!status.linked);
|
||||
@@ -701,7 +873,7 @@ mod tests {
|
||||
#[test]
|
||||
fn checklist_items_can_be_added_ticked_retitled_and_removed() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app
|
||||
.create_note(NoteDraft {
|
||||
body: "Packing".to_string(),
|
||||
@@ -755,7 +927,7 @@ mod tests {
|
||||
#[test]
|
||||
fn setting_labels_leaves_tag_derived_ones_alone() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let note = app
|
||||
.create_note(draft("Trip\nbook the ferry #travel"))
|
||||
@@ -796,7 +968,7 @@ mod tests {
|
||||
#[test]
|
||||
fn deleting_forever_removes_the_note() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
|
||||
|
||||
app.delete_note_forever(note.id.clone())
|
||||
@@ -809,11 +981,61 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A file attached here lands in the blob store, is findable by its hash, and
|
||||
/// leaves both the note and the board's view of it when removed.
|
||||
#[test]
|
||||
fn an_attached_file_is_stored_found_and_removable() {
|
||||
let dir = scratch_dir();
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Receipt")).expect("create");
|
||||
|
||||
let attached = app
|
||||
.add_attachment(
|
||||
note.id.clone(),
|
||||
"receipt.png".into(),
|
||||
"image/png".into(),
|
||||
b"not really a png".to_vec(),
|
||||
)
|
||||
.expect("attach");
|
||||
assert_eq!(attached.attachments.len(), 1);
|
||||
let att = &attached.attachments[0];
|
||||
assert_eq!(att.filename.as_deref(), Some("receipt.png"));
|
||||
assert_eq!(att.mime, "image/png");
|
||||
assert_eq!(att.upload_error, None);
|
||||
|
||||
let sha = att.sha256.clone().expect("a stored file carries its hash");
|
||||
let path = app.blob_path(sha.clone()).expect("the bytes are on disk");
|
||||
assert_eq!(std::fs::read(path).unwrap(), b"not really a png");
|
||||
assert_eq!(
|
||||
app.blob_path("0".repeat(64)),
|
||||
None,
|
||||
"an unknown hash has no path"
|
||||
);
|
||||
|
||||
let after = app
|
||||
.delete_attachment(note.id.clone(), att.id.clone())
|
||||
.expect("remove");
|
||||
assert!(after.attachments.is_empty());
|
||||
|
||||
assert!(
|
||||
app.add_attachment(
|
||||
"missing".into(),
|
||||
"a.txt".into(),
|
||||
"text/plain".into(),
|
||||
vec![1]
|
||||
)
|
||||
.is_err(),
|
||||
"attaching to a note that doesn't exist is refused"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Snooze writes a future instant from the CORE's clock; complete clears it.
|
||||
#[test]
|
||||
fn reminders_can_be_snoozed_and_completed() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Call back")).expect("create");
|
||||
assert_eq!(note.remind_at, None);
|
||||
|
||||
@@ -839,7 +1061,7 @@ mod tests {
|
||||
#[test]
|
||||
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Water the plants")).expect("create");
|
||||
|
||||
let armed = app
|
||||
@@ -892,6 +1114,81 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Renaming a tag onto a name another tag already holds MERGES the two, and the
|
||||
/// OLDER row is the survivor.
|
||||
///
|
||||
/// Before this, the bare UPDATE met `idx_labels_name` — unique on `lower(name)` —
|
||||
/// and the user got a raw "UNIQUE constraint failed" from SQLite. Merging is what
|
||||
/// a person means by typing an existing tag's name onto this one.
|
||||
#[test]
|
||||
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
|
||||
let dir = scratch_dir();
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let older = app.create_label("grocery".to_string()).expect("older");
|
||||
// `created_at` is RFC3339 to the MILLISECOND. Without a gap the two rows can
|
||||
// share a timestamp, and then the tie-break is under test instead of the age
|
||||
// rule this test is about.
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let newer = app.create_label("errands".to_string()).expect("newer");
|
||||
|
||||
let one = app.create_note(draft("milk")).expect("note one");
|
||||
let two = app.create_note(draft("stamps")).expect("note two");
|
||||
app.set_note_labels(one.id.clone(), vec![older.id.clone()])
|
||||
.expect("tag one");
|
||||
app.set_note_labels(two.id.clone(), vec![newer.id.clone()])
|
||||
.expect("tag two");
|
||||
|
||||
// The YOUNGER one is renamed onto the older's name, in a different case —
|
||||
// matching is case-insensitive, and the survivor takes the spelling asked for.
|
||||
let survivor = app
|
||||
.rename_label(newer.id.clone(), "Grocery".to_string())
|
||||
.expect("a rename onto an existing name merges instead of failing");
|
||||
|
||||
assert_eq!(
|
||||
survivor.id, older.id,
|
||||
"the older row is the one that survives"
|
||||
);
|
||||
assert_eq!(survivor.name, "Grocery", "spelled the way the caller asked");
|
||||
|
||||
let all = app.list_labels().expect("list");
|
||||
assert_eq!(all.len(), 1, "the two became one");
|
||||
assert_eq!(all[0].id, older.id);
|
||||
assert_eq!(all[0].count, Some(2), "carrying every note from both sides");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The mirror of the test above. Renaming the OLDER one onto the younger's name
|
||||
/// still leaves the older row standing — it just changes its name.
|
||||
///
|
||||
/// This is the whole reason age decides rather than "whoever already held the
|
||||
/// name": otherwise the survivor depends on which way round someone typed it,
|
||||
/// and two devices tidying the same pair would disagree about which id exists.
|
||||
#[test]
|
||||
fn the_rename_merge_survivor_does_not_depend_on_the_direction() {
|
||||
let dir = scratch_dir();
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let older = app.create_label("grocery".to_string()).expect("older");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let newer = app.create_label("errands".to_string()).expect("newer");
|
||||
|
||||
let survivor = app
|
||||
.rename_label(older.id.clone(), "errands".to_string())
|
||||
.expect("rename");
|
||||
|
||||
assert_eq!(survivor.id, older.id, "age wins in this direction too");
|
||||
assert_eq!(survivor.name, "errands");
|
||||
assert_ne!(
|
||||
survivor.id, newer.id,
|
||||
"the younger row is the one that went"
|
||||
);
|
||||
assert_eq!(app.list_labels().expect("list").len(), 1);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
|
||||
/// crate's dev-dependencies to assert one field is well-formed.
|
||||
fn chrono_free_parse(raw: &str) -> usize {
|
||||
|
||||
+104
-17
@@ -1,6 +1,6 @@
|
||||
//! The types that cross into Kotlin.
|
||||
//!
|
||||
//! These MIRROR `thoughtsync_core::local::models` rather than reusing it. The core's
|
||||
//! These MIRROR `inkwell_core::local::models` rather than reusing it. The core's
|
||||
//! shapes are serde structs whose field names and optionality are contracted with the
|
||||
//! shared Vue frontend; hanging uniffi derives on them would couple two very
|
||||
//! different consumers to one definition and put a `serde_json::Value` (which has no
|
||||
@@ -13,13 +13,13 @@
|
||||
//! what to do with it. That is the entire reason for the `let Core { .. } = value`
|
||||
//! style here; please keep it.
|
||||
|
||||
use thoughtsync_core::local::models as core_models;
|
||||
use thoughtsync_core::sync::client as core_client;
|
||||
use thoughtsync_core::sync::compat as core_compat;
|
||||
use thoughtsync_core::sync::engine as core_engine;
|
||||
use thoughtsync_core::sync::pull as core_pull;
|
||||
use thoughtsync_core::sync::push as core_push;
|
||||
use thoughtsync_core::sync::state as core_state;
|
||||
use inkwell_core::local::models as core_models;
|
||||
use inkwell_core::sync::client as core_client;
|
||||
use inkwell_core::sync::compat as core_compat;
|
||||
use inkwell_core::sync::engine as core_engine;
|
||||
use inkwell_core::sync::pull as core_pull;
|
||||
use inkwell_core::sync::push as core_push;
|
||||
use inkwell_core::sync::state as core_state;
|
||||
|
||||
/// A note, with everything needed to render a card or open the editor.
|
||||
///
|
||||
@@ -46,6 +46,20 @@ pub struct Note {
|
||||
pub previews: Vec<LinkPreview>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
/// How this account holds the note (#5175): "owner", or "edit"/"view" for one
|
||||
/// someone shared with it.
|
||||
pub permission: String,
|
||||
/// Whether the owner has shared it with anyone.
|
||||
pub shared: bool,
|
||||
/// Who shared it with us; absent on our own notes.
|
||||
pub shared_by: Option<SharedBy>,
|
||||
}
|
||||
|
||||
/// The owner of a note shared with this account.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct SharedBy {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// A checklist item as it sits in a note's body.
|
||||
@@ -60,9 +74,9 @@ pub struct BodyItem {
|
||||
pub checked: bool,
|
||||
}
|
||||
|
||||
impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
|
||||
fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self {
|
||||
let thoughtsync_core::local::derive::DerivedItem {
|
||||
impl From<inkwell_core::local::derive::DerivedItem> for BodyItem {
|
||||
fn from(i: inkwell_core::local::derive::DerivedItem) -> Self {
|
||||
let inkwell_core::local::derive::DerivedItem {
|
||||
text,
|
||||
checked,
|
||||
line,
|
||||
@@ -88,9 +102,9 @@ pub struct BodyTag {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl From<thoughtsync_core::local::derive::DerivedTag> for BodyTag {
|
||||
fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self {
|
||||
let thoughtsync_core::local::derive::DerivedTag {
|
||||
impl From<inkwell_core::local::derive::DerivedTag> for BodyTag {
|
||||
fn from(t: inkwell_core::local::derive::DerivedTag) -> Self {
|
||||
let inkwell_core::local::derive::DerivedTag {
|
||||
line,
|
||||
start,
|
||||
end,
|
||||
@@ -121,12 +135,12 @@ pub struct ClientUpdate {
|
||||
pub size: i64,
|
||||
}
|
||||
|
||||
impl From<thoughtsync_core::sync::client::ClientRelease> for ClientUpdate {
|
||||
fn from(r: thoughtsync_core::sync::client::ClientRelease) -> Self {
|
||||
impl From<inkwell_core::sync::client::ClientRelease> for ClientUpdate {
|
||||
fn from(r: inkwell_core::sync::client::ClientRelease) -> Self {
|
||||
// Destructured exhaustively, like every other conversion in this file: a
|
||||
// field added upstream stops this compiling until Android is told what to
|
||||
// do with it, which turns silent drift into a build error.
|
||||
let thoughtsync_core::sync::client::ClientRelease {
|
||||
let inkwell_core::sync::client::ClientRelease {
|
||||
version,
|
||||
version_code,
|
||||
size,
|
||||
@@ -167,6 +181,8 @@ pub struct Attachment {
|
||||
pub mime: String,
|
||||
pub size: Option<i64>,
|
||||
pub sha256: Option<String>,
|
||||
/// Why the server refused a file attached on this device. None otherwise.
|
||||
pub upload_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
@@ -199,6 +215,9 @@ impl From<core_models::Note> for Note {
|
||||
previews,
|
||||
created_at,
|
||||
updated_at,
|
||||
permission,
|
||||
shared,
|
||||
shared_by,
|
||||
} = value;
|
||||
Note {
|
||||
id,
|
||||
@@ -217,6 +236,12 @@ impl From<core_models::Note> for Note {
|
||||
previews: previews.into_iter().map(LinkPreview::from).collect(),
|
||||
created_at,
|
||||
updated_at,
|
||||
permission,
|
||||
shared,
|
||||
shared_by: shared_by.map(|by| SharedBy {
|
||||
id: by.id,
|
||||
display_name: by.display_name,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +289,7 @@ impl From<core_models::Attachment> for Attachment {
|
||||
mime,
|
||||
size,
|
||||
sha256,
|
||||
upload_error,
|
||||
} = value;
|
||||
Attachment {
|
||||
id,
|
||||
@@ -272,6 +298,7 @@ impl From<core_models::Attachment> for Attachment {
|
||||
mime,
|
||||
size,
|
||||
sha256,
|
||||
upload_error,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,6 +373,8 @@ pub struct NoteFacets {
|
||||
pub has_attachment: Option<bool>,
|
||||
pub created_after: Option<String>,
|
||||
pub created_before: Option<String>,
|
||||
/// "with_me": only notes someone else shared with this account.
|
||||
pub shared: Option<String>,
|
||||
}
|
||||
|
||||
impl From<NoteQuery> for core_models::ListQuery {
|
||||
@@ -374,6 +403,7 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
has_attachment,
|
||||
created_after,
|
||||
created_before,
|
||||
shared,
|
||||
} = value;
|
||||
core_models::Facets {
|
||||
q,
|
||||
@@ -382,6 +412,7 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
has_attachment,
|
||||
created_after,
|
||||
created_before,
|
||||
shared,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -582,6 +613,54 @@ impl From<core_client::Identity> for Identity {
|
||||
}
|
||||
}
|
||||
|
||||
/// Someone on the instance a note can be shared with (#5175).
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct Member {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl From<core_client::Member> for Member {
|
||||
fn from(value: core_client::Member) -> Self {
|
||||
let core_client::Member {
|
||||
id,
|
||||
display_name,
|
||||
email,
|
||||
} = value;
|
||||
Member {
|
||||
id,
|
||||
display_name,
|
||||
email,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One person a note is shared with, at "view" or "edit".
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteShare {
|
||||
pub id: String,
|
||||
pub member: Member,
|
||||
pub permission: String,
|
||||
}
|
||||
|
||||
impl From<core_client::NoteShare> for NoteShare {
|
||||
fn from(value: core_client::NoteShare) -> Self {
|
||||
// `created_at` stays behind: nothing on the phone orders or shows by it.
|
||||
let core_client::NoteShare {
|
||||
id,
|
||||
member,
|
||||
permission,
|
||||
created_at: _,
|
||||
} = value;
|
||||
NoteShare {
|
||||
id,
|
||||
member: member.into(),
|
||||
permission,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of this device's token on the server during an unlink.
|
||||
///
|
||||
/// Separate from the local result because the local half always succeeds and the
|
||||
@@ -637,6 +716,10 @@ pub struct PushSummary {
|
||||
/// realistic case). Silently retrying forever would be the wrong shape.
|
||||
pub rejected: u64,
|
||||
pub errors: Vec<String>,
|
||||
/// Files attached on this device that reached the server this cycle.
|
||||
pub uploaded: u64,
|
||||
/// Files that didn't; their reasons are in `errors`.
|
||||
pub upload_failed: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
@@ -668,6 +751,8 @@ impl From<core_push::PushSummary> for PushSummary {
|
||||
noop,
|
||||
rejected,
|
||||
errors,
|
||||
uploaded,
|
||||
upload_failed,
|
||||
} = value;
|
||||
PushSummary {
|
||||
batches: batches as u64,
|
||||
@@ -678,6 +763,8 @@ impl From<core_push::PushSummary> for PushSummary {
|
||||
noop: noop as u64,
|
||||
rejected: rejected as u64,
|
||||
errors,
|
||||
uploaded: uploaded as u64,
|
||||
upload_failed: upload_failed as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Where the generated Kotlin lands. Matches the app's package so the bindings are
|
||||
# `com.fabledsword.thoughtsync.core.*` rather than something the app has to alias.
|
||||
# `com.fabledsword.inkwell.core.*` rather than something the app has to alias.
|
||||
[bindings.kotlin]
|
||||
package_name = "com.fabledsword.thoughtsync.core"
|
||||
cdylib_name = "thoughtsync_ffi"
|
||||
package_name = "com.fabledsword.inkwell.core"
|
||||
cdylib_name = "inkwell_ffi"
|
||||
|
||||
@@ -18,7 +18,7 @@ dependencyResolutionManagement {
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "ThoughtSync"
|
||||
rootProject.name = "Inkwell"
|
||||
|
||||
// `ffi/` sits beside `app/` but is deliberately NOT a Gradle module: it is a Rust
|
||||
// crate belonging to the Cargo workspace at the repo root. Gradle reaches it by
|
||||
|
||||
+35
-29
@@ -1,4 +1,4 @@
|
||||
# CI Requirements — ThoughtSync
|
||||
# CI Requirements — Inkwell
|
||||
|
||||
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
|
||||
> in the CI-Runner repo.
|
||||
@@ -39,28 +39,26 @@ entirely on `ci-python:3.14`.
|
||||
install` cold cost is a non-blocker.
|
||||
- Build gates on `typecheck` + `lint` only. The `test` job runs in parallel for
|
||||
visibility but does not block the dev image push. DB-backed / integration tests
|
||||
run against the dev image manually — ThoughtSync's unit tests are DB-free (no
|
||||
run against the dev image manually — Inkwell's unit tests are DB-free (no
|
||||
Postgres service lane in CI yet).
|
||||
- `dev` push -> `:dev` + `:<sha>`; `v*` tag -> `:latest` + `:<version>` + `:<sha>`
|
||||
(family rule 46).
|
||||
- The production runtime `Dockerfile` tracks python:3.12 so test results stay
|
||||
representative of the deployed image.
|
||||
- **Artifacts — use the mirrored upload action, never `actions/upload-artifact`.**
|
||||
- **Artifacts — stock `actions/upload-artifact@v7`, never `@v3`.**
|
||||
```yaml
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
uses: actions/upload-artifact@v7
|
||||
```
|
||||
Upstream's `actions/upload-artifact@v4` cannot work against this instance and
|
||||
no server-side change will help: its `isGhes()` rejects any hostname that isn't
|
||||
`github.com` / `*.ghe.com` / `*.localhost` and throws before it opens a
|
||||
connection, so the server is never asked what it supports. `@v3` is worse — it
|
||||
reports success, and Gitea then serves artifacts back only through the v4 API
|
||||
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
|
||||
to every retrieval path. A green job producing nothing retrievable.
|
||||
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.
|
||||
|
||||
`bvandeusen/upload-artifact` is our pull mirror of `forgejo/upload-artifact`
|
||||
(the Forgejo project's fork, one commit on upstream v5.0.0 disabling that
|
||||
check). Mirrored so CI depends on a commit we hold; pinned by SHA because the
|
||||
mirror auto-syncs and a moved upstream tag would otherwise change what runs.
|
||||
`@v3` is still broken: it reports success, and Gitea serves artifacts back only
|
||||
through the v4 API (`content_encoding = application/zip`), so a v3 upload is
|
||||
stored but invisible to every retrieval path. A green job producing nothing
|
||||
retrievable.
|
||||
|
||||
Both desktop upload steps also set `if-no-files-found: error` and carry **no**
|
||||
`continue-on-error`. They previously had both defaults inverted, which is how
|
||||
@@ -89,7 +87,7 @@ parts. Three of them are family rules for a reason:
|
||||
`docker ps` by it. A spaced or underscored name breaks the filter.
|
||||
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves
|
||||
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and
|
||||
builds `THOUGHTSYNC_DATABASE_URL` from it. `postgres:5432` will not connect.
|
||||
builds `INKWELL_DATABASE_URL` from it. `postgres:5432` will not connect.
|
||||
- **`run:` is busybox sh** (rule 81) — no `/dev/tcp` — so the readiness wait is a small
|
||||
Python heredoc. Its terminator must dedent to column 0 after YAML strips the block
|
||||
indent; check with `yaml.safe_load` and print the `run` string if you edit it.
|
||||
@@ -207,7 +205,7 @@ backend/frontend push.
|
||||
## Android lane — being rebuilt (M12)
|
||||
|
||||
The Tauri-mobile Android lane is gone. Android is a native Kotlin/Compose client
|
||||
over the shared `thoughtsync-core` crate instead — see Scribe note 2730 for the
|
||||
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
|
||||
@@ -220,7 +218,7 @@ second image, and JDK 25 (which requires **Gradle 9.1+** in this repo's wrapper
|
||||
the old JDK 17 pin existed only because Tauri generated a Gradle 8.x project).
|
||||
|
||||
The Rust pin is in LOCKSTEP with `ci-tauri` and `ci-tauri-win`. All three build
|
||||
`thoughtsync-core` from one workspace `Cargo.lock` under `--locked`, so a
|
||||
`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.
|
||||
|
||||
@@ -335,8 +333,9 @@ differs from CI is worse than none.
|
||||
|
||||
**This reproduces CI exactly, not approximately.** On the 2026-08-18 run the
|
||||
local test binary hashes (`thoughtsync_core-bbaae79723888ad1`,
|
||||
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`)
|
||||
matched CI run 3931's byte for byte. Same image, same lockfile, same units.
|
||||
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`,
|
||||
named for the crates as they were before the rename to Inkwell) matched CI run
|
||||
3931's byte for byte. Same image, same lockfile, same units.
|
||||
|
||||
`target/` persists on the host between runs, so after the first cold build these
|
||||
take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it
|
||||
@@ -411,18 +410,25 @@ 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.
|
||||
|
||||
## Pushing: `dev` is both a branch and a tag
|
||||
## Channel releases: `dev-rolling` and `stable`
|
||||
|
||||
`git push origin dev` fails in this repo:
|
||||
The two update channels are releases on fixed tags, because Fabled-Git has no
|
||||
`/releases/latest/download/<asset>` route and the updater needs a permanent URL.
|
||||
The **channel** is still called `dev` everywhere a person sees it; its **release
|
||||
tag** is `dev-rolling`. `packaging/channel-tag.sh` is the one mapping CI reads.
|
||||
`desktop/src-tauri/src/update.rs` and `desktop/packaging/install.sh` carry their
|
||||
own copy because neither can run it — change all three together.
|
||||
|
||||
```
|
||||
error: src refspec dev matches more than one
|
||||
```
|
||||
The tag used to be `dev`, which shadowed the branch of the same name: once a clone
|
||||
had fetched it, `git push origin dev` failed with
|
||||
`error: src refspec dev matches more than one` (Scribe #2184). Never name a channel
|
||||
tag after a branch; `tests/test_channel_tag.py` and the `update.rs` tests fail if
|
||||
one is.
|
||||
|
||||
The rolling update channel is a release on a **fixed tag named `dev`** (the tag
|
||||
never moves — Fabled-Git has no `/releases/latest/download/<asset>` route, so the
|
||||
updater needs a permanent URL). Once that tag is fetched locally, the short name
|
||||
`dev` resolves to both `refs/heads/dev` and `refs/tags/dev`. Fully qualify it:
|
||||
**Transitional, from 2026-09-10:** the old `dev` release still exists so desktop
|
||||
apps installed from it can update across — the manifest job writes `latest.json`
|
||||
to it too (`BRIDGE_TAG=dev` in `desktop.yml`). Until that release and its tag are
|
||||
deleted, fully qualify pushes:
|
||||
|
||||
```
|
||||
git push origin refs/heads/dev:refs/heads/dev
|
||||
|
||||
+11
-2
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "thoughtsync-core"
|
||||
name = "inkwell-core"
|
||||
version = "0.1.0"
|
||||
description = "ThoughtSync 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"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -26,6 +26,15 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||
# Verifying downloaded attachment bytes against the sha256 the server advertised.
|
||||
sha2 = "0.10"
|
||||
# Export and import with no server (local/portable.rs): the same zip the server
|
||||
# writes and reads, so a backup crosses between surfaces. Deflate only — every
|
||||
# other method (bzip2, zstd, lzma, AES) is off, since neither the server's exports
|
||||
# nor Google Takeout use them and several pull in C code.
|
||||
zip = { version = "4", default-features = false, features = ["deflate-flate2"] }
|
||||
# zip's deflate goes through flate2, which needs a backend chosen. miniz_oxide
|
||||
# (`rust_backend`) is pure Rust, so the Windows and Android cross-compiles stay
|
||||
# free of C; both crates were already in the lockfile, via the updater and png.
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
|
||||
# Android has no system OpenSSL to link against, and `native-tls` resolves to
|
||||
# OpenSSL there — unlike Windows, where it lands on schannel and costs nothing.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
//! ThoughtSync's client core: the on-device SQLite store and the sync engine.
|
||||
//! Inkwell's client core: the on-device SQLite store and the sync engine.
|
||||
//!
|
||||
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
|
||||
//! the Android client binds it through uniffi. Neither owns it, and a change to
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep
|
||||
//! with the frontend's inline rules (see frontend notes/markdown.ts):
|
||||
//!
|
||||
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
|
||||
//! On save these become labels attached with `via_tag = true`.
|
||||
//! - `#tag`: `#` at the start of a line or after whitespace, then a letter, then
|
||||
//! letters, digits, `_` and `-`. On save these become labels attached with
|
||||
//! `via_tag = true`. The server and the web run the same cases
|
||||
//! (core/testdata/grammar.json).
|
||||
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
|
||||
//! table of items beside it, so a list can sit between two paragraphs instead of
|
||||
//! only after them.
|
||||
@@ -29,7 +31,11 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '#' {
|
||||
let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#');
|
||||
// Start of line or after whitespace, and nothing else. Any non-tag
|
||||
// character used to count, which made `(#todo)` a tag here and plain
|
||||
// text on the server, and made the `/#section` of a pasted URL a label.
|
||||
// Whitespace is the rule all three implementations now share (#5166).
|
||||
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).
|
||||
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
|
||||
let mut j = i + 1;
|
||||
@@ -770,4 +776,78 @@ mod tests {
|
||||
assert_eq!(touched, body);
|
||||
assert_eq!(extract_items(&touched), items);
|
||||
}
|
||||
|
||||
// ── the shared fixture ───────────────────────────────────────────────────
|
||||
//
|
||||
// core/testdata/grammar.json is the one set of cases the server (pytest), the web
|
||||
// (vitest) and this file all run. The cases above stay as this file's own
|
||||
// reasoning; these are the ones the other languages have agreed to.
|
||||
|
||||
fn fixture() -> serde_json::Value {
|
||||
serde_json::from_str(include_str!("../../testdata/grammar.json"))
|
||||
.expect("grammar.json parses")
|
||||
}
|
||||
|
||||
fn strings(v: &serde_json::Value) -> Vec<String> {
|
||||
v.as_array()
|
||||
.expect("an array")
|
||||
.iter()
|
||||
.map(|s| s.as_str().expect("a string").to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_task_lines() {
|
||||
for case in fixture()["task_lines"].as_array().unwrap() {
|
||||
let line = case["line"].as_str().unwrap();
|
||||
let got: Vec<(String, bool)> = extract_items(line)
|
||||
.into_iter()
|
||||
.map(|i| (i.text, i.checked))
|
||||
.collect();
|
||||
let want: Vec<(String, bool)> = match &case["item"] {
|
||||
serde_json::Value::Null => Vec::new(),
|
||||
item => vec![(
|
||||
item["text"].as_str().unwrap().to_string(),
|
||||
item["checked"].as_bool().unwrap(),
|
||||
)],
|
||||
};
|
||||
assert_eq!(got, want, "line {line:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_rendered_items() {
|
||||
for case in fixture()["rendered_items"].as_array().unwrap() {
|
||||
let text = case["text"].as_str().unwrap();
|
||||
let checked = case["checked"].as_bool().unwrap();
|
||||
assert_eq!(render_item(text, checked), case["line"].as_str().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_tags() {
|
||||
for case in fixture()["tags"].as_array().unwrap() {
|
||||
let body = case["body"].as_str().unwrap();
|
||||
assert_eq!(extract_tags(body), strings(&case["tags"]), "body {body:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_lifts() {
|
||||
for case in fixture()["lifts"].as_array().unwrap() {
|
||||
let body = case["body"].as_str().unwrap();
|
||||
let (standalone, inline, lifted) = lift_standalone_tags(body);
|
||||
assert_eq!(
|
||||
standalone,
|
||||
strings(&case["standalone"]),
|
||||
"standalone, body {body:?}"
|
||||
);
|
||||
assert_eq!(inline, strings(&case["inline"]), "inline, body {body:?}");
|
||||
assert_eq!(
|
||||
lifted,
|
||||
case["lifted"].as_str().unwrap(),
|
||||
"lifted, body {body:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod derive;
|
||||
pub mod models;
|
||||
pub mod portable;
|
||||
pub mod recur;
|
||||
pub mod retention;
|
||||
pub mod schema;
|
||||
|
||||
@@ -28,6 +28,20 @@ pub struct Note {
|
||||
pub previews: Vec<LinkPreview>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
/// How this account holds the note (#5175): `owner`, or `edit`/`view` for one
|
||||
/// someone shared with it. Named and valued as the server's, so the shared
|
||||
/// frontend gates the editor identically either way.
|
||||
pub permission: String,
|
||||
/// Whether the owner has shared it with anyone.
|
||||
pub shared: bool,
|
||||
/// Who shared it with us; null on our own notes.
|
||||
pub shared_by: Option<SharedBy>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SharedBy {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -55,6 +69,10 @@ pub struct Attachment {
|
||||
pub mime: String,
|
||||
pub size: Option<i64>,
|
||||
pub sha256: Option<String>,
|
||||
/// Why the server refused a file attached on this device, in words to show on it.
|
||||
/// Absent for everything else, including a file still waiting to upload.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upload_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -91,6 +109,19 @@ pub struct TitleEntry {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// A reminder that has come due, as the desktop's reminder worker needs it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct DueReminder {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
/// `remind_at` as stored. It names the occurrence: completing or snoozing a
|
||||
/// reminder changes it, and that is how an announcer tells a new occurrence
|
||||
/// from one it has already announced.
|
||||
pub remind_at: String,
|
||||
/// The same instant, in milliseconds since the epoch.
|
||||
pub due_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SavedFilter {
|
||||
pub id: String,
|
||||
@@ -156,4 +187,7 @@ pub struct Facets {
|
||||
pub created_after: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_before: Option<String>,
|
||||
/// `with_me`: only notes someone else shared with this account.
|
||||
#[serde(default)]
|
||||
pub shared: Option<String>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,894 @@
|
||||
//! A whole store as a zip, out and back in, with no server.
|
||||
//!
|
||||
//! The same archive the server writes at `GET /api/notes/export` and reads at
|
||||
//! `POST /api/notes/import` (`src/inkwell/notes/import_export.py`), so a backup taken
|
||||
//! on one surface restores on any other. Both copies are held to
|
||||
//! `core/testdata/portable.json`; change the format there first.
|
||||
//!
|
||||
//! - **Export:** `notes.json` (the machine format), a Markdown file per note for
|
||||
//! reading, and each attachment this device holds.
|
||||
//! - **Import:** an Inkwell export (either app marker) or a Google Keep Takeout zip.
|
||||
//! Additive: notes are created, never matched against existing ones. Decompression
|
||||
//! is capped per entry and in total, so a zip bomb fails instead of filling memory.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||
|
||||
use super::models::{Note, NoteCreateInput};
|
||||
use super::{derive, recur, store};
|
||||
use crate::sync::blobs::BlobStore;
|
||||
|
||||
/// The `app` an export's notes.json carries. "thoughtsync" is what every export
|
||||
/// written before the rename says, and those are people's backups.
|
||||
const APP_MARKERS: [&str; 2] = ["inkwell", "thoughtsync"];
|
||||
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
const MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const MAX_TOTAL_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// What an import did, in the server's words.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ImportSummary {
|
||||
/// "inkwell" or "keep".
|
||||
pub source: String,
|
||||
pub imported: usize,
|
||||
pub skipped: usize,
|
||||
}
|
||||
|
||||
/// One note as either importer reads it, before anything is written. Mirrors the
|
||||
/// server's "common import spec".
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ImportSpec {
|
||||
pub title: Option<String>,
|
||||
pub body: String,
|
||||
pub pinned: bool,
|
||||
pub archived: bool,
|
||||
pub trashed: bool,
|
||||
pub remind_at: Option<String>,
|
||||
pub recurrence: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub labels: Vec<String>,
|
||||
pub items: Vec<(String, bool)>,
|
||||
/// (path inside the zip, mime if the source said)
|
||||
pub attachments: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
// ---- export -----------------------------------------------------------------
|
||||
|
||||
/// The export's file name without `.zip`, dated like the server's:
|
||||
/// `inkwell-export-YYYYMMDD`.
|
||||
pub fn export_stem() -> String {
|
||||
format!("inkwell-export-{}", Utc::now().format("%Y%m%d"))
|
||||
}
|
||||
|
||||
/// Every live note (not trashed) as an export archive.
|
||||
pub fn export_zip(conn: &Connection, blobs: &BlobStore) -> Result<Vec<u8>, String> {
|
||||
let ids: Vec<String> = {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM notes WHERE trashed = 0 ORDER BY created_at")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| r.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.collect::<rusqlite::Result<_>>()
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
let labels: Vec<Value> = store::list_labels(conn)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|l| json!({ "name": l.name, "color": l.color }))
|
||||
.collect();
|
||||
|
||||
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
let mut notes = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
let note = store::get_note(conn, id).map_err(|e| e.to_string())?;
|
||||
let short: String = note.id.chars().take(8).collect();
|
||||
|
||||
let mut files = Vec::new();
|
||||
for (i, att) in note.attachments.iter().enumerate() {
|
||||
// A file this device hasn't downloaded yet is left out of the list as well
|
||||
// as the archive, so an importer never goes looking for it.
|
||||
let Some(bytes) = att.sha256.as_deref().and_then(|sha| blobs.read(sha)) else {
|
||||
continue;
|
||||
};
|
||||
// A folder per attachment, so two files with one name in one note don't
|
||||
// collide, and the importer still gets the real name from the basename.
|
||||
let name = store::safe_filename(att.filename.as_deref().unwrap_or(""));
|
||||
let path = format!("attachments/{short}/{i}/{name}");
|
||||
write_entry(&mut zip, &path, &bytes, opts)?;
|
||||
files.push(json!({ "file": path, "mime": att.mime }));
|
||||
}
|
||||
|
||||
let md = format!("notes/{}-{short}.md", slugify(¬e.display_title));
|
||||
write_entry(&mut zip, &md, note_markdown(¬e).as_bytes(), opts)?;
|
||||
notes.push(json!({
|
||||
"id": note.id,
|
||||
"display_title": note.display_title,
|
||||
"body": note.body,
|
||||
"pinned": note.pinned,
|
||||
"archived": note.archived,
|
||||
"remind_at": note.remind_at,
|
||||
"recurrence": note.recurrence,
|
||||
"created_at": note.created_at,
|
||||
"updated_at": note.updated_at,
|
||||
"labels": note.labels.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
|
||||
"attachments": files,
|
||||
}));
|
||||
}
|
||||
|
||||
let doc = json!({
|
||||
"app": "inkwell",
|
||||
"version": 1,
|
||||
"exported_at": Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||
"labels": labels,
|
||||
"notes": notes,
|
||||
});
|
||||
let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?;
|
||||
write_entry(&mut zip, "notes.json", text.as_bytes(), opts)?;
|
||||
zip.finish()
|
||||
.map(Cursor::into_inner)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn write_entry(
|
||||
zip: &mut ZipWriter<Cursor<Vec<u8>>>,
|
||||
path: &str,
|
||||
bytes: &[u8],
|
||||
opts: SimpleFileOptions,
|
||||
) -> Result<(), String> {
|
||||
zip.start_file(path, opts).map_err(|e| e.to_string())?;
|
||||
zip.write_all(bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// One note as a readable Markdown file with a small frontmatter block. notes.json is
|
||||
/// the machine format; this is for a person opening the archive. Mirrors
|
||||
/// `_note_markdown` on the server.
|
||||
pub fn note_markdown(note: &Note) -> String {
|
||||
let mut out = vec![
|
||||
"---".to_string(),
|
||||
format!("display_name: {}", note.display_title),
|
||||
];
|
||||
if !note.labels.is_empty() {
|
||||
let names: Vec<&str> = note.labels.iter().map(|l| l.name.as_str()).collect();
|
||||
out.push(format!("labels: [{}]", names.join(", ")));
|
||||
}
|
||||
if note.pinned {
|
||||
out.push("pinned: true".into());
|
||||
}
|
||||
if note.archived {
|
||||
out.push("archived: true".into());
|
||||
}
|
||||
if let Some(at) = ¬e.remind_at {
|
||||
out.push(format!("remind_at: {at}"));
|
||||
}
|
||||
out.push(format!(
|
||||
"created: {}",
|
||||
note.created_at.as_deref().unwrap_or("")
|
||||
));
|
||||
out.push(format!(
|
||||
"updated: {}",
|
||||
note.updated_at.as_deref().unwrap_or("")
|
||||
));
|
||||
out.push("---".into());
|
||||
out.push(String::new());
|
||||
if !note.body.is_empty() {
|
||||
out.push(note.body.clone());
|
||||
}
|
||||
out.join("\n") + "\n"
|
||||
}
|
||||
|
||||
/// A filesystem-safe slug from a note's name, for its .md file. Mirrors `_slugify`:
|
||||
/// keep word characters, spaces and hyphens; collapse runs of space, underscore and
|
||||
/// hyphen into one hyphen; at most 60 characters; "note" when nothing is left.
|
||||
pub fn slugify(text: &str) -> String {
|
||||
let kept: String = text
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || c.is_whitespace())
|
||||
.collect();
|
||||
let mut slug = String::new();
|
||||
let mut gap = false;
|
||||
for c in kept.chars() {
|
||||
if c.is_whitespace() || c == '_' || c == '-' {
|
||||
gap = true;
|
||||
} else {
|
||||
if gap && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
}
|
||||
gap = false;
|
||||
slug.push(c);
|
||||
}
|
||||
}
|
||||
let slug: String = slug.chars().take(60).collect();
|
||||
let slug = slug.trim_end_matches('-').to_string();
|
||||
if slug.is_empty() {
|
||||
"note".into()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
// ---- import -----------------------------------------------------------------
|
||||
|
||||
/// Bytes pulled out of the archive, capped per entry and across the whole import.
|
||||
/// Each read stops one byte past what is left, so an oversized or size-lying entry
|
||||
/// is caught mid-read rather than after it has been inflated.
|
||||
struct Budget {
|
||||
remaining: u64,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
fn read(
|
||||
&mut self,
|
||||
zip: &mut ZipArchive<Cursor<&[u8]>>,
|
||||
name: &str,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let cap = MAX_ENTRY_BYTES.min(self.remaining);
|
||||
let entry = match zip.by_name(name) {
|
||||
Ok(entry) => entry,
|
||||
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let mut data = Vec::new();
|
||||
entry
|
||||
.take(cap + 1)
|
||||
.read_to_end(&mut data)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if data.len() as u64 > cap {
|
||||
return Err(TOO_LARGE.into());
|
||||
}
|
||||
self.remaining -= data.len() as u64;
|
||||
Ok(Some(data))
|
||||
}
|
||||
}
|
||||
|
||||
const TOO_LARGE: &str = "that archive is too large to import";
|
||||
|
||||
/// Import an Inkwell export or a Google Keep Takeout zip into this store.
|
||||
///
|
||||
/// All or nothing for the notes: any failure rolls the whole import back. Attachment
|
||||
/// bytes already written to the blob store stay there, which is harmless — they are
|
||||
/// keyed by content and nothing points at them.
|
||||
pub fn import_zip(
|
||||
conn: &Connection,
|
||||
blobs: &BlobStore,
|
||||
bytes: &[u8],
|
||||
) -> Result<ImportSummary, String> {
|
||||
let mut zip = ZipArchive::new(Cursor::new(bytes))
|
||||
.map_err(|_| "that file isn't a valid .zip archive".to_string())?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err("that archive has too many files to import".into());
|
||||
}
|
||||
let mut budget = Budget {
|
||||
remaining: MAX_TOTAL_BYTES,
|
||||
};
|
||||
let (specs, source) = read_specs(&mut zip, &mut budget)?;
|
||||
if specs.is_empty() {
|
||||
return Err(
|
||||
"no importable notes found — expected an Inkwell export or a Google Keep Takeout zip"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
|
||||
let mut summary = ImportSummary {
|
||||
source,
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
for spec in &specs {
|
||||
if create_imported(&tx, blobs, spec, &mut zip, &mut budget)? {
|
||||
summary.imported += 1;
|
||||
} else {
|
||||
summary.skipped += 1;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn read_specs(
|
||||
zip: &mut ZipArchive<Cursor<&[u8]>>,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(Vec<ImportSpec>, String), String> {
|
||||
let names: Vec<String> = zip.file_names().map(str::to_string).collect();
|
||||
for name in names.iter().filter(|n| basename(n) == "notes.json") {
|
||||
let Some(raw) = budget.read(zip, name)? else {
|
||||
continue;
|
||||
};
|
||||
let Ok(doc) = serde_json::from_slice::<Value>(&raw) else {
|
||||
continue;
|
||||
};
|
||||
let marker = doc.get("app").and_then(Value::as_str).unwrap_or("");
|
||||
if APP_MARKERS.contains(&marker) {
|
||||
let specs = doc
|
||||
.get("notes")
|
||||
.and_then(Value::as_array)
|
||||
.map(|notes| {
|
||||
notes
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.map(native_spec)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
return Ok((specs, "inkwell".into()));
|
||||
}
|
||||
}
|
||||
|
||||
const KEEP_KEYS: [&str; 6] = [
|
||||
"textContent",
|
||||
"listContent",
|
||||
"isPinned",
|
||||
"isArchived",
|
||||
"isTrashed",
|
||||
"userEditedTimestampUsec",
|
||||
];
|
||||
let mut specs = Vec::new();
|
||||
for name in &names {
|
||||
if !name.to_lowercase().ends_with(".json") || basename(name) == "notes.json" {
|
||||
continue;
|
||||
}
|
||||
let Some(raw) = budget.read(zip, name)? else {
|
||||
continue;
|
||||
};
|
||||
let Ok(Value::Object(note)) = serde_json::from_slice::<Value>(&raw) else {
|
||||
continue;
|
||||
};
|
||||
if KEEP_KEYS.iter().any(|k| note.contains_key(*k)) {
|
||||
specs.push(keep_spec(¬e, dirname(name)));
|
||||
}
|
||||
}
|
||||
let source = if specs.is_empty() { "" } else { "keep" };
|
||||
Ok((specs, source.into()))
|
||||
}
|
||||
|
||||
/// One note from an Inkwell export's notes.json. Mirrors `_native_spec`.
|
||||
pub fn native_spec(n: &Map<String, Value>) -> ImportSpec {
|
||||
ImportSpec {
|
||||
title: str_of(n, "title"),
|
||||
body: str_of(n, "body").unwrap_or_default(),
|
||||
pinned: bool_of(n, "pinned"),
|
||||
archived: bool_of(n, "archived"),
|
||||
trashed: false, // an export carries only live notes
|
||||
remind_at: str_of(n, "remind_at").and_then(|s| instant(&s)),
|
||||
recurrence: recur::normalize(n.get("recurrence").and_then(Value::as_str))
|
||||
.map(str::to_string),
|
||||
created_at: str_of(n, "created_at").and_then(|s| instant(&s)),
|
||||
updated_at: str_of(n, "updated_at").and_then(|s| instant(&s)),
|
||||
labels: strings(n.get("labels")),
|
||||
items: objects(n.get("items"))
|
||||
.map(|it| {
|
||||
(
|
||||
str_of(it, "text").unwrap_or_default(),
|
||||
bool_of(it, "checked"),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
attachments: objects(n.get("attachments"))
|
||||
.filter_map(|a| {
|
||||
Some((
|
||||
str_of(a, "file").filter(|f| !f.is_empty())?,
|
||||
str_of(a, "mime"),
|
||||
))
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One Google Keep note (a Takeout `<note>.json`). `dir` is the folder the JSON sits
|
||||
/// in, which its attachment paths are relative to. Mirrors `_keep_spec`.
|
||||
pub fn keep_spec(k: &Map<String, Value>, dir: &str) -> ImportSpec {
|
||||
// Keep stores links separately from the text; fold them in so they survive.
|
||||
let mut body = str_of(k, "textContent").unwrap_or_default();
|
||||
let urls: Vec<String> = objects(k.get("annotations"))
|
||||
.filter_map(|a| str_of(a, "url"))
|
||||
.filter(|u| !u.is_empty() && !body.contains(u.as_str()))
|
||||
.collect();
|
||||
if !urls.is_empty() {
|
||||
let extra = urls.join("\n");
|
||||
body = if body.trim().is_empty() {
|
||||
extra
|
||||
} else {
|
||||
format!("{body}\n\n{extra}")
|
||||
};
|
||||
}
|
||||
|
||||
let attachments = objects(k.get("attachments"))
|
||||
.filter_map(|a| {
|
||||
let fp = str_of(a, "filePath").filter(|p| !p.is_empty())?;
|
||||
let file = if dir.is_empty() {
|
||||
fp.clone()
|
||||
} else {
|
||||
format!("{dir}/{fp}")
|
||||
};
|
||||
let mime = str_of(a, "mimetype").or_else(|| mime_from_name(&fp).map(str::to_string));
|
||||
Some((file, mime))
|
||||
})
|
||||
.collect();
|
||||
|
||||
ImportSpec {
|
||||
title: str_of(k, "title"),
|
||||
body,
|
||||
pinned: bool_of(k, "isPinned"),
|
||||
archived: bool_of(k, "isArchived"),
|
||||
trashed: bool_of(k, "isTrashed"),
|
||||
remind_at: None, // Keep's reminders aren't in its Takeout JSON
|
||||
recurrence: None,
|
||||
created_at: k.get("createdTimestampUsec").and_then(usec_instant),
|
||||
updated_at: k.get("userEditedTimestampUsec").and_then(usec_instant),
|
||||
labels: objects(k.get("labels"))
|
||||
.filter_map(|l| str_of(l, "name"))
|
||||
.collect(),
|
||||
items: objects(k.get("listContent"))
|
||||
.map(|li| {
|
||||
(
|
||||
str_of(li, "text").unwrap_or_default(),
|
||||
bool_of(li, "isChecked"),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one imported note. False, with nothing written, when there is nothing in it.
|
||||
fn create_imported(
|
||||
conn: &Connection,
|
||||
blobs: &BlobStore,
|
||||
spec: &ImportSpec,
|
||||
zip: &mut ZipArchive<Cursor<&[u8]>>,
|
||||
budget: &mut Budget,
|
||||
) -> Result<bool, String> {
|
||||
// An imported title becomes the first body line — Inkwell has no title field, and
|
||||
// dropping it would lose text someone wrote. Skipped when the body already opens
|
||||
// with it, so re-importing an export doesn't stack duplicates.
|
||||
let mut body = spec.body.clone();
|
||||
let title = spec.title.as_deref().unwrap_or("").trim();
|
||||
let first_line = body.trim_start().split('\n').next().unwrap_or("").trim();
|
||||
if !title.is_empty() && first_line != title {
|
||||
body = if body.trim().is_empty() {
|
||||
title.to_string()
|
||||
} else {
|
||||
format!("{title}\n{body}")
|
||||
};
|
||||
}
|
||||
let items: Vec<(&str, bool)> = spec
|
||||
.items
|
||||
.iter()
|
||||
.map(|(t, c)| (t.trim(), *c))
|
||||
.filter(|(t, _)| !t.is_empty())
|
||||
.collect();
|
||||
// A note that is only a photo is still a note — Keep has plenty of them.
|
||||
if body.trim().is_empty() && items.is_empty() && spec.attachments.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
for (text, checked) in &items {
|
||||
body = derive::append_item(&body, text, *checked);
|
||||
}
|
||||
|
||||
let err = |e: rusqlite::Error| e.to_string();
|
||||
let note = store::create_note(conn, &NoteCreateInput { body, items: None }).map_err(err)?;
|
||||
for name in spec
|
||||
.labels
|
||||
.iter()
|
||||
.map(|n| n.trim())
|
||||
.filter(|n| !n.is_empty())
|
||||
{
|
||||
let label = store::create_label(conn, name).map_err(err)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)",
|
||||
params![note.id, label.id],
|
||||
)
|
||||
.map_err(err)?;
|
||||
}
|
||||
for (file, mime) in &spec.attachments {
|
||||
let Some(raw) = budget.read(zip, file)? else {
|
||||
continue;
|
||||
};
|
||||
store::add_attachment(
|
||||
conn,
|
||||
blobs,
|
||||
¬e.id,
|
||||
basename(file),
|
||||
mime.as_deref().unwrap_or(""),
|
||||
&raw,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Last, because adding an attachment touches the note: the source's own times are
|
||||
// what this note should carry, not the moment it was imported.
|
||||
let trashed_at = spec
|
||||
.trashed
|
||||
.then(|| Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true));
|
||||
conn.execute(
|
||||
"UPDATE notes SET pinned = ?1, archived = ?2, remind_at = ?3, recurrence = ?4,
|
||||
trashed = ?5, trashed_at = ?6,
|
||||
created_at = COALESCE(?7, created_at), updated_at = COALESCE(?8, updated_at)
|
||||
WHERE id = ?9",
|
||||
params![
|
||||
spec.pinned,
|
||||
spec.archived,
|
||||
spec.remind_at,
|
||||
spec.recurrence,
|
||||
spec.trashed,
|
||||
trashed_at,
|
||||
spec.created_at,
|
||||
spec.updated_at,
|
||||
note.id
|
||||
],
|
||||
)
|
||||
.map_err(err)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ---- small readers --------------------------------------------------------------
|
||||
|
||||
fn str_of(m: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
m.get(key).and_then(Value::as_str).map(str::to_string)
|
||||
}
|
||||
|
||||
fn bool_of(m: &Map<String, Value>, key: &str) -> bool {
|
||||
m.get(key).and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn objects(v: Option<&Value>) -> impl Iterator<Item = &Map<String, Value>> {
|
||||
v.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_object)
|
||||
}
|
||||
|
||||
fn strings(v: Option<&Value>) -> Vec<String> {
|
||||
v.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Any RFC 3339 instant, in the store's own form (UTC, milliseconds, `Z`). Anything
|
||||
/// else is treated as absent, as the server's `parse_dt` does.
|
||||
fn instant(raw: &str) -> Option<String> {
|
||||
DateTime::parse_from_rfc3339(raw).ok().map(|t| {
|
||||
t.with_timezone(&Utc)
|
||||
.to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep's timestamps: integer microseconds since the epoch.
|
||||
fn usec_instant(v: &Value) -> Option<String> {
|
||||
let usec = v.as_i64().or_else(|| v.as_str()?.parse().ok())?;
|
||||
DateTime::from_timestamp_micros(usec).map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||
}
|
||||
|
||||
/// The image types the server infers from a name when the source gave no mime.
|
||||
fn mime_from_name(name: &str) -> Option<&'static str> {
|
||||
let ext = name.rsplit_once('.')?.1.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"png" => Some("image/png"),
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
"gif" => Some("image/gif"),
|
||||
"webp" => Some("image/webp"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn basename(path: &str) -> &str {
|
||||
path.rsplit('/').next().unwrap_or(path)
|
||||
}
|
||||
|
||||
fn dirname(path: &str) -> &str {
|
||||
path.rsplit_once('/').map(|(d, _)| d).unwrap_or("")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::local::schema;
|
||||
|
||||
fn store() -> (Connection, BlobStore, std::path::PathBuf) {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
schema::migrate(&conn).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("inkwell-portable-{}", uuid::Uuid::new_v4()));
|
||||
let blobs = BlobStore::new(dir.clone()).unwrap();
|
||||
(conn, blobs, dir)
|
||||
}
|
||||
|
||||
fn all(conn: &Connection) -> Vec<Note> {
|
||||
let q = super::super::models::ListQuery {
|
||||
view: "notes".into(),
|
||||
label_id: None,
|
||||
facets: None,
|
||||
sort: None,
|
||||
};
|
||||
store::list_notes(conn, &q).unwrap()
|
||||
}
|
||||
|
||||
fn fixture() -> Value {
|
||||
serde_json::from_str(include_str!("../../testdata/portable.json"))
|
||||
.expect("portable.json parses")
|
||||
}
|
||||
|
||||
/// The fixture's expected spec against ours, field by field, timestamps as instants.
|
||||
fn assert_spec(got: &ImportSpec, want: &Value, about: &str) {
|
||||
let s = |k: &str| want.get(k).and_then(Value::as_str).map(str::to_string);
|
||||
let t = |k: &str| s(k).map(|v| DateTime::parse_from_rfc3339(&v).unwrap());
|
||||
let ours = |v: &Option<String>| {
|
||||
v.as_deref()
|
||||
.map(|x| DateTime::parse_from_rfc3339(x).unwrap())
|
||||
};
|
||||
assert_eq!(got.title, s("title"), "{about}: title");
|
||||
assert_eq!(Some(got.body.clone()), s("body"), "{about}: body");
|
||||
assert_eq!(
|
||||
Some(got.pinned),
|
||||
want["pinned"].as_bool(),
|
||||
"{about}: pinned"
|
||||
);
|
||||
assert_eq!(
|
||||
Some(got.archived),
|
||||
want["archived"].as_bool(),
|
||||
"{about}: archived"
|
||||
);
|
||||
assert_eq!(
|
||||
Some(got.trashed),
|
||||
want["trashed"].as_bool(),
|
||||
"{about}: trashed"
|
||||
);
|
||||
assert_eq!(got.labels, strings(want.get("labels")), "{about}: labels");
|
||||
let items: Vec<(String, bool)> = objects(want.get("items"))
|
||||
.map(|i| (str_of(i, "text").unwrap(), bool_of(i, "checked")))
|
||||
.collect();
|
||||
assert_eq!(got.items, items, "{about}: items");
|
||||
let atts: Vec<(String, Option<String>)> = objects(want.get("attachments"))
|
||||
.map(|a| (str_of(a, "file").unwrap(), str_of(a, "mime")))
|
||||
.collect();
|
||||
assert_eq!(got.attachments, atts, "{about}: attachments");
|
||||
assert_eq!(
|
||||
ours(&got.created_at),
|
||||
t("created_at"),
|
||||
"{about}: created_at"
|
||||
);
|
||||
assert_eq!(
|
||||
ours(&got.updated_at),
|
||||
t("updated_at"),
|
||||
"{about}: updated_at"
|
||||
);
|
||||
if want.get("remind_at").is_some() {
|
||||
assert_eq!(ours(&got.remind_at), t("remind_at"), "{about}: remind_at");
|
||||
}
|
||||
if want.get("recurrence").is_some() {
|
||||
assert_eq!(got.recurrence, s("recurrence"), "{about}: recurrence");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_notes_read_as_the_fixture_says() {
|
||||
let cases = fixture()["keep"].as_array().unwrap().clone();
|
||||
assert!(!cases.is_empty());
|
||||
for case in &cases {
|
||||
let note = case["note"].as_object().unwrap();
|
||||
let got = keep_spec(note, case["dir"].as_str().unwrap());
|
||||
assert_spec(&got, &case["spec"], case["about"].as_str().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_notes_read_as_the_fixture_says() {
|
||||
let cases = fixture()["native"].as_array().unwrap().clone();
|
||||
assert!(!cases.is_empty());
|
||||
for case in &cases {
|
||||
let got = native_spec(case["note"].as_object().unwrap());
|
||||
assert_spec(&got, &case["spec"], case["about"].as_str().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
fn zip_of(files: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
for (name, bytes) in files {
|
||||
write_entry(&mut zip, name, bytes, opts).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn sorted_keys(v: &Value) -> Vec<String> {
|
||||
let mut keys: Vec<String> = v.as_object().unwrap().keys().cloned().collect();
|
||||
keys.sort();
|
||||
keys
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_writes_the_fixture_keys_and_imports_back_whole() {
|
||||
let (conn, blobs, dir) = store();
|
||||
let note = store::create_note(
|
||||
&conn,
|
||||
&NoteCreateInput {
|
||||
body: "Trip #travel\n\n- [ ] passport".into(),
|
||||
items: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
store::update_note(&conn, ¬e.id, &json!({ "pinned": true, "recurrence": "weekly", "remind_at": "2026-11-01T09:00:00.000Z" })).unwrap();
|
||||
store::add_attachment(
|
||||
&conn,
|
||||
&blobs,
|
||||
¬e.id,
|
||||
"ticket.pdf",
|
||||
"application/pdf",
|
||||
b"%PDF-1",
|
||||
)
|
||||
.unwrap();
|
||||
let trashed = store::create_note(
|
||||
&conn,
|
||||
&NoteCreateInput {
|
||||
body: "gone".into(),
|
||||
items: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
store::trash(&conn, &trashed.id).unwrap();
|
||||
|
||||
let archive = export_zip(&conn, &blobs).unwrap();
|
||||
|
||||
// The shape, against the keys the server's export is held to as well.
|
||||
let keys = &fixture()["export"];
|
||||
let mut zip = ZipArchive::new(Cursor::new(archive.as_slice())).unwrap();
|
||||
let mut raw = String::new();
|
||||
zip.by_name("notes.json")
|
||||
.unwrap()
|
||||
.read_to_string(&mut raw)
|
||||
.unwrap();
|
||||
let doc: Value = serde_json::from_str(&raw).unwrap();
|
||||
let want = |k: &str| {
|
||||
let mut v = strings(keys.get(k));
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
assert_eq!(sorted_keys(&doc), want("document"));
|
||||
assert_eq!(doc["app"], "inkwell");
|
||||
let notes = doc["notes"].as_array().unwrap();
|
||||
assert_eq!(notes.len(), 1, "the trashed note is not exported");
|
||||
assert_eq!(sorted_keys(¬es[0]), want("note"));
|
||||
assert_eq!(sorted_keys(&doc["labels"][0]), want("label"));
|
||||
let att = ¬es[0]["attachments"][0];
|
||||
assert_eq!(sorted_keys(att), want("attachment"));
|
||||
let mut bytes = Vec::new();
|
||||
zip.by_name(att["file"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.read_to_end(&mut bytes)
|
||||
.unwrap();
|
||||
assert_eq!(bytes, b"%PDF-1");
|
||||
assert!(zip
|
||||
.file_names()
|
||||
.any(|n| n.starts_with("notes/trip-travel-") && n.ends_with(".md")));
|
||||
|
||||
// And back into an empty store, whole.
|
||||
let (fresh, fresh_blobs, fresh_dir) = store();
|
||||
let summary = import_zip(&fresh, &fresh_blobs, &archive).unwrap();
|
||||
assert_eq!(
|
||||
summary,
|
||||
ImportSummary {
|
||||
source: "inkwell".into(),
|
||||
imported: 1,
|
||||
skipped: 0
|
||||
}
|
||||
);
|
||||
let back = all(&fresh);
|
||||
assert_eq!(back.len(), 1);
|
||||
let back = &back[0];
|
||||
let original = store::get_note(&conn, ¬e.id).unwrap();
|
||||
assert_eq!(back.body, original.body);
|
||||
assert!(back.pinned);
|
||||
assert_eq!(back.recurrence.as_deref(), Some("weekly"));
|
||||
assert_eq!(back.remind_at, original.remind_at);
|
||||
assert_eq!(
|
||||
back.created_at, original.created_at,
|
||||
"the source's own time, not the import's"
|
||||
);
|
||||
assert_eq!(
|
||||
back.labels
|
||||
.iter()
|
||||
.map(|l| l.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["travel"]
|
||||
);
|
||||
assert_eq!(back.attachments.len(), 1);
|
||||
assert_eq!(back.attachments[0].filename.as_deref(), Some("ticket.pdf"));
|
||||
let sha = back.attachments[0].sha256.clone().unwrap();
|
||||
assert_eq!(fresh_blobs.read(&sha).unwrap(), b"%PDF-1");
|
||||
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
std::fs::remove_dir_all(fresh_dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keep_takeout_imports_including_a_photo_only_note() {
|
||||
let (conn, blobs, dir) = store();
|
||||
let list = serde_json::to_vec(&fixture()["keep"][0]["note"]).unwrap();
|
||||
let photo_only =
|
||||
br#"{"textContent": "", "isPinned": false, "attachments": [{"filePath": "p.png"}]}"#;
|
||||
let archive = zip_of(&[
|
||||
("Takeout/Keep/Groceries.json", list),
|
||||
("Takeout/Keep/photo.jpg", b"jpeg bytes".to_vec()),
|
||||
("Takeout/Keep/scan.png", b"png bytes".to_vec()),
|
||||
("Takeout/Keep/Photo.json", photo_only.to_vec()),
|
||||
("Takeout/Keep/p.png", b"p".to_vec()),
|
||||
(
|
||||
"Takeout/Keep/empty.json",
|
||||
br#"{"textContent": " "}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"Takeout/Keep/unrelated.json",
|
||||
br#"{"hello": "world"}"#.to_vec(),
|
||||
),
|
||||
]);
|
||||
let summary = import_zip(&conn, &blobs, &archive).unwrap();
|
||||
assert_eq!(
|
||||
summary,
|
||||
ImportSummary {
|
||||
source: "keep".into(),
|
||||
imported: 2,
|
||||
skipped: 1
|
||||
}
|
||||
);
|
||||
|
||||
let notes = all(&conn);
|
||||
let groceries = notes
|
||||
.iter()
|
||||
.find(|n| n.body.starts_with("Groceries"))
|
||||
.expect("the list note");
|
||||
assert!(groceries.body.contains("- [x] Eggs"));
|
||||
assert!(groceries.pinned);
|
||||
assert_eq!(groceries.attachments.len(), 2);
|
||||
assert_eq!(
|
||||
groceries.created_at.as_deref(),
|
||||
Some("2020-09-13T12:26:40.000Z")
|
||||
);
|
||||
let photo = notes
|
||||
.iter()
|
||||
.find(|n| n.body.is_empty())
|
||||
.expect("the photo-only note");
|
||||
assert_eq!(photo.attachments[0].mime, "image/png");
|
||||
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_an_archive_or_holds_no_notes_is_refused() {
|
||||
let (conn, blobs, dir) = store();
|
||||
assert!(import_zip(&conn, &blobs, b"not a zip")
|
||||
.unwrap_err()
|
||||
.contains("valid .zip"));
|
||||
let other = zip_of(&[(
|
||||
"notes.json",
|
||||
br#"{"app": "someone-else", "notes": [{"body": "x"}]}"#.to_vec(),
|
||||
)]);
|
||||
assert!(import_zip(&conn, &blobs, &other)
|
||||
.unwrap_err()
|
||||
.contains("no importable notes"));
|
||||
assert!(all(&conn).is_empty());
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_match_the_servers() {
|
||||
assert_eq!(slugify("Trip #travel"), "trip-travel");
|
||||
assert_eq!(slugify(" Hello, World! "), "hello-world");
|
||||
assert_eq!(slugify("snake_case -- dashes"), "snake-case-dashes");
|
||||
assert_eq!(slugify("!!!"), "note");
|
||||
assert_eq!(slugify(""), "note");
|
||||
assert_eq!(slugify(&"a".repeat(80)).len(), 60);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Recurring-reminder math: where a reminder goes when it is marked done.
|
||||
//!
|
||||
//! A deliberate port of the server's `src/thoughtsync/notes/recurrence.py`, kept
|
||||
//! A deliberate port of the server's `src/inkwell/notes/recurrence.py`, kept
|
||||
//! behaviourally identical rather than merely similar. The same note can be
|
||||
//! completed from the web (server code) or from the desktop and Android (this
|
||||
//! code), and the two must land on the same instant — otherwise completing a
|
||||
|
||||
@@ -37,7 +37,8 @@ pub fn sweep_expired_trash(
|
||||
let mut expired: Vec<String> = Vec::new();
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, trashed_at FROM notes WHERE trashed = 1 AND trashed_at IS NOT NULL",
|
||||
"SELECT id, trashed_at FROM notes
|
||||
WHERE trashed = 1 AND trashed_at IS NOT NULL AND permission = 'owner'",
|
||||
)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
|
||||
@@ -274,6 +274,57 @@ UPDATE saved_filters
|
||||
AND params LIKE '%"color"%';
|
||||
"#;
|
||||
|
||||
// v10 (#5168): an attachment can be created on THIS device.
|
||||
//
|
||||
// Until now every attachment row arrived on the delta feed, so every one was already on
|
||||
// the server. A file attached here, offline, is not, and `uploaded = 0` is the queue
|
||||
// push drains once the note has landed. The rows already here all came from the
|
||||
// server, which is why the default is 1.
|
||||
//
|
||||
// `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
|
||||
// queue: a file refused for its size would otherwise be re-sent in full on every
|
||||
// cycle, every five minutes, for as long as the app was open. The message is what the
|
||||
// editor shows on the file instead.
|
||||
const SCHEMA_V10: &str = r#"
|
||||
ALTER TABLE attachments ADD COLUMN uploaded INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE attachments ADD COLUMN upload_error TEXT;
|
||||
"#;
|
||||
|
||||
// v11 (#5175): notes other people shared with this account.
|
||||
//
|
||||
// The feed now 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
|
||||
// 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.
|
||||
//
|
||||
// `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
|
||||
// first pull from a server offering `shares` starts again from zero (see
|
||||
// `engine::run_cycle`). A pull applies idempotently, so starting over costs a
|
||||
// download and nothing else.
|
||||
const SCHEMA_V11: &str = r#"
|
||||
ALTER TABLE notes ADD COLUMN permission TEXT NOT NULL DEFAULT 'owner';
|
||||
ALTER TABLE notes ADD COLUMN shared INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE notes ADD COLUMN shared_by_id TEXT;
|
||||
ALTER TABLE notes ADD COLUMN shared_by_name TEXT;
|
||||
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.
|
||||
//
|
||||
// `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
|
||||
// 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.
|
||||
//
|
||||
// `sync_state.shares_synced` now holds a level rather than a flag (see
|
||||
// `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.
|
||||
const SCHEMA_V12: &str = r#"
|
||||
ALTER TABLE notes ADD COLUMN state_at TEXT;
|
||||
"#;
|
||||
|
||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
@@ -313,6 +364,18 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(SCHEMA_V9)?;
|
||||
conn.execute_batch("PRAGMA user_version = 9;")?;
|
||||
}
|
||||
if version < 10 {
|
||||
conn.execute_batch(SCHEMA_V10)?;
|
||||
conn.execute_batch("PRAGMA user_version = 10;")?;
|
||||
}
|
||||
if version < 11 {
|
||||
conn.execute_batch(SCHEMA_V11)?;
|
||||
conn.execute_batch("PRAGMA user_version = 11;")?;
|
||||
}
|
||||
if version < 12 {
|
||||
conn.execute_batch(SCHEMA_V12)?;
|
||||
conn.execute_batch("PRAGMA user_version = 12;")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -427,7 +490,34 @@ mod tests {
|
||||
let version: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.expect("version");
|
||||
assert_eq!(version, 9);
|
||||
assert_eq!(version, 12);
|
||||
}
|
||||
|
||||
/// Every attachment that predates v10 came down the feed, so it is already on the
|
||||
/// server. Defaulting it to "waiting to upload" would re-send every file once.
|
||||
#[test]
|
||||
fn v10_counts_existing_attachments_as_already_on_the_server() {
|
||||
let conn = v7_db();
|
||||
migrate_v8(&conn).expect("v8");
|
||||
conn.execute_batch(SCHEMA_V9).expect("v9");
|
||||
conn.execute_batch("PRAGMA user_version = 9;").expect("v9");
|
||||
add_note(&conn, "n", "a note");
|
||||
conn.execute(
|
||||
"INSERT INTO attachments (id, note_id, url) VALUES ('a', 'n', '/x')",
|
||||
[],
|
||||
)
|
||||
.expect("seed");
|
||||
|
||||
migrate(&conn).expect("migrate");
|
||||
let (uploaded, error): (bool, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT uploaded, upload_error FROM attachments WHERE id = 'a'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.expect("row");
|
||||
assert!(uploaded);
|
||||
assert_eq!(error, None);
|
||||
}
|
||||
|
||||
/// The column is gone, not merely unread. Asserted by asking SQLite rather than by
|
||||
|
||||
+1143
-30
File diff suppressed because it is too large
Load Diff
+27
-5
@@ -78,6 +78,15 @@ impl BlobStore {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// File bytes this device produced (a file attached here) and return their hash.
|
||||
/// The hash is computed from the bytes, so unlike [`store`](Self::store) there is
|
||||
/// nothing to verify against.
|
||||
pub fn put(&self, bytes: &[u8]) -> Result<String, String> {
|
||||
let hash = digest(bytes);
|
||||
self.store(&hash, bytes)?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
pub fn read(&self, sha256: &str) -> Option<Vec<u8>> {
|
||||
fs::read(self.path(sha256)?).ok()
|
||||
}
|
||||
@@ -140,7 +149,9 @@ fn urlencode(value: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn urldecode(value: &str) -> String {
|
||||
/// Undo percent-encoding. Also used for the filename the desktop's attach command
|
||||
/// receives in a header, which can only carry ASCII.
|
||||
pub fn urldecode(value: &str) -> String {
|
||||
let bytes = value.as_bytes();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
@@ -163,12 +174,14 @@ fn urldecode(value: &str) -> String {
|
||||
///
|
||||
/// The mime rides in the URL and this scheme is an origin of its own, so echoing an
|
||||
/// arbitrary type would let an attachment claiming `text/html` run as a document
|
||||
/// there. Echoing is safe only because of the FAMILY check: nothing starting with
|
||||
/// `image/` can name a scriptable type. Everything else is served as an opaque
|
||||
/// download — the right treatment for an arbitrary file regardless.
|
||||
/// there. Echoing is safe only for the families that can't carry script, and
|
||||
/// `image/` is not quite one of them: `image/svg+xml` is a document that runs its own
|
||||
/// `<script>`, so it is served as an opaque download like everything else unfamiliar.
|
||||
/// The web made the same exclusion for the same reason (#1981).
|
||||
fn content_type_for(mime: &str) -> String {
|
||||
const RENDERABLE: &[&str] = &["image/", "audio/", "video/"];
|
||||
let familiar = RENDERABLE.iter().any(|p| mime.starts_with(p)) || mime == "application/pdf";
|
||||
let familiar = (RENDERABLE.iter().any(|p| mime.starts_with(p)) && mime != "image/svg+xml")
|
||||
|| mime == "application/pdf";
|
||||
// A header value can't carry control characters, and a mime type has no business
|
||||
// being long — both would only arrive from a malformed or hostile feed.
|
||||
let printable = mime.len() <= 100 && mime.bytes().all(|b| b.is_ascii_graphic());
|
||||
@@ -295,6 +308,8 @@ mod tests {
|
||||
let opaque = "application/octet-stream";
|
||||
assert_eq!(content_type_for("text/html"), opaque);
|
||||
assert_eq!(content_type_for("application/javascript"), opaque);
|
||||
// An image family member that is really a document with script in it.
|
||||
assert_eq!(content_type_for("image/svg+xml"), opaque);
|
||||
assert_eq!(content_type_for(""), opaque);
|
||||
// A control character can't reach a header value even under a safe family.
|
||||
assert_eq!(content_type_for("image/png\r\nX-Evil: 1"), opaque);
|
||||
@@ -309,6 +324,13 @@ mod tests {
|
||||
assert!(body.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_files_bytes_under_their_own_hash() {
|
||||
let store = store("put");
|
||||
assert_eq!(store.put(b"hello").expect("put"), HELLO);
|
||||
assert_eq!(store.read(HELLO).as_deref(), Some(&b"hello"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_blob_reads_as_none() {
|
||||
let store = store("missing");
|
||||
|
||||
+202
-8
@@ -1,4 +1,4 @@
|
||||
//! HTTP transport to a ThoughtSync server.
|
||||
//! HTTP transport to an Inkwell server.
|
||||
//!
|
||||
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
|
||||
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
|
||||
@@ -27,6 +27,11 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// failing one at ten seconds would make a large store impossible to ever pull.
|
||||
const SYNC_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// One file going up can be far larger than a page of notes, and a timeout shorter
|
||||
/// than the transfer is not a failure that retrying ever fixes — the same upload
|
||||
/// would time out again every cycle.
|
||||
const UPLOAD_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Shared by every call that presents a token, so a revoked one reads the same way
|
||||
/// wherever it surfaces.
|
||||
const TOKEN_REJECTED: &str = "This server rejected the device token — it may have been \
|
||||
@@ -151,8 +156,8 @@ fn unexpected_status(base_url: &str, status: StatusCode) -> String {
|
||||
|
||||
/// Ask a server who it is and whether we can sync with it.
|
||||
///
|
||||
/// `Err` means we never got a usable answer (bad address, unreachable, not a
|
||||
/// ThoughtSync server). A server that answers but is *incompatible* comes back `Ok`
|
||||
/// `Err` means we never got a usable answer (bad address, unreachable, not an
|
||||
/// Inkwell server). A server that answers but is *incompatible* comes back `Ok`
|
||||
/// with a verdict — that distinction matters, because the two need very different
|
||||
/// messages: one is "check what you typed", the other is "update something".
|
||||
pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
|
||||
@@ -170,12 +175,12 @@ pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
|
||||
return Err(unexpected_status(&base_url, status));
|
||||
}
|
||||
|
||||
// Something answered 200 that isn't a ThoughtSync server (a router login page, a
|
||||
// Something answered 200 that isn't an Inkwell server (a router login page, a
|
||||
// captive portal). Report the address, not the parse error, which would mean
|
||||
// nothing to the person reading it.
|
||||
let server: ServerInfo = response.json().await.map_err(|_| {
|
||||
format!(
|
||||
"{base_url} responded, but not with ThoughtSync's configuration. \
|
||||
"{base_url} responded, but not with Inkwell's configuration. \
|
||||
Is that the right address?"
|
||||
)
|
||||
})?;
|
||||
@@ -260,8 +265,15 @@ pub async fn fetch_changes(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
since: i64,
|
||||
shares: bool,
|
||||
) -> Result<wire::ChangesPage, String> {
|
||||
let url = format!("{base_url}/api/sync/changes?since={since}");
|
||||
// `shares=1` only to a server advertising `shares`: it asks for the notes shared
|
||||
// with this account and the `revoked` list, which an older server would ignore.
|
||||
let url = if shares {
|
||||
format!("{base_url}/api/sync/changes?since={since}&shares=1")
|
||||
} else {
|
||||
format!("{base_url}/api/sync/changes?since={since}")
|
||||
};
|
||||
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
||||
let response = request
|
||||
.send()
|
||||
@@ -314,6 +326,71 @@ pub async fn fetch_attachment(
|
||||
.map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
/// Why an upload didn't land, split by whether trying again could help.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum UploadError {
|
||||
/// Worth another attempt next cycle: the network, a server error, or a note the
|
||||
/// server hasn't got yet.
|
||||
Retry(String),
|
||||
/// The server refused this file and will refuse it the same way every time —
|
||||
/// too large, an id in use, bytes that don't match their hash.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// Upload one file attached on this device, under the id it was given here.
|
||||
///
|
||||
/// `PUT /api/sync/attachments/<id>` takes the raw bytes; idempotent, so a retry of
|
||||
/// an upload whose reply was lost answers 200 and stores nothing twice.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn upload_attachment(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
note_id: &str,
|
||||
attachment_id: &str,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
sha256: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<(), UploadError> {
|
||||
let url = format!("{base_url}/api/sync/attachments/{attachment_id}");
|
||||
let client = http_with(UPLOAD_TIMEOUT).map_err(UploadError::Retry)?;
|
||||
let request = prepare(client.put(url), Some(token))
|
||||
.query(&[
|
||||
("note_id", note_id),
|
||||
("filename", filename),
|
||||
("sha256", sha256),
|
||||
])
|
||||
.header(reqwest::header::CONTENT_TYPE, mime)
|
||||
.body(bytes);
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| UploadError::Retry(describe_transport_error(base_url, &e)))?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err(UploadError::Retry(TOKEN_REJECTED.to_string()));
|
||||
}
|
||||
// The server's own words, when it gave some: "file is too large (max 25 MB)" is
|
||||
// what a person can act on, and a bare status code is not.
|
||||
let reason = response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
if status.is_client_error() && status != StatusCode::NOT_FOUND {
|
||||
Err(UploadError::Refused(reason))
|
||||
} else {
|
||||
// 404: the note isn't on the server yet (its push was rejected, say). 5xx:
|
||||
// the server's problem, and likely a passing one.
|
||||
Err(UploadError::Retry(reason))
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a batch of changes and hand back the raw reply.
|
||||
///
|
||||
/// Returns text rather than parsed results so this module stays pure transport —
|
||||
@@ -346,6 +423,123 @@ pub async fn push_changes<T: Serialize>(
|
||||
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
// --- sharing (#5175) -----------------------------------------------------------
|
||||
//
|
||||
// The Share dialog on a linked device asks the server directly, over the device
|
||||
// token: who is on the instance, and who a note is shared with. Shares are the
|
||||
// server's, so there is nothing to keep offline and nothing to queue.
|
||||
|
||||
/// Someone on the instance a note can be shared with.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct Member {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// One person a note is shared with, and at what level (`view` or `edit`).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct NoteShare {
|
||||
pub id: String,
|
||||
pub member: Member,
|
||||
pub permission: String,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MembersReply {
|
||||
members: Vec<Member>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SharesReply {
|
||||
shares: Vec<NoteShare>,
|
||||
}
|
||||
|
||||
/// A note made on this device that hasn't been pushed yet is a 404 to the server,
|
||||
/// and so is one this account doesn't own.
|
||||
const SHARE_NOT_FOUND: &str = "The server doesn't have this note yet. Sync, then share it.";
|
||||
|
||||
async fn read_reply<T: serde::de::DeserializeOwned>(
|
||||
base_url: &str,
|
||||
request: RequestBuilder,
|
||||
) -> Result<T, String> {
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err(TOKEN_REJECTED.to_string());
|
||||
}
|
||||
if status == StatusCode::NOT_FOUND {
|
||||
return Err(SHARE_NOT_FOUND.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
// The server's own words when it gave some ("choose someone to share with").
|
||||
let reason = response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from));
|
||||
return Err(reason.unwrap_or_else(|| unexpected_status(base_url, status)));
|
||||
}
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't read the reply from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
/// Everyone on the instance but this account.
|
||||
pub async fn directory(base_url: &str, token: &str) -> Result<Vec<Member>, String> {
|
||||
let url = format!("{base_url}/api/users/directory");
|
||||
let reply: MembersReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?;
|
||||
Ok(reply.members)
|
||||
}
|
||||
|
||||
pub async fn list_shares(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
note_id: &str,
|
||||
) -> Result<Vec<NoteShare>, String> {
|
||||
let url = format!("{base_url}/api/notes/{note_id}/shares");
|
||||
let reply: SharesReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?;
|
||||
Ok(reply.shares)
|
||||
}
|
||||
|
||||
/// Share with one member, or change their permission. Answers the note's shares.
|
||||
pub async fn share_note(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
note_id: &str,
|
||||
user_id: &str,
|
||||
permission: &str,
|
||||
) -> Result<Vec<NoteShare>, String> {
|
||||
let url = format!("{base_url}/api/notes/{note_id}/shares");
|
||||
let body = serde_json::json!({ "user_id": user_id, "permission": permission });
|
||||
let reply: SharesReply = read_reply(
|
||||
base_url,
|
||||
prepare(http()?.post(url), Some(token)).json(&body),
|
||||
)
|
||||
.await?;
|
||||
Ok(reply.shares)
|
||||
}
|
||||
|
||||
pub async fn unshare_note(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
note_id: &str,
|
||||
share_id: &str,
|
||||
) -> Result<Vec<NoteShare>, String> {
|
||||
let url = format!("{base_url}/api/notes/{note_id}/shares/{share_id}");
|
||||
let reply: SharesReply =
|
||||
read_reply(base_url, prepare(http()?.delete(url), Some(token))).await?;
|
||||
Ok(reply.shares)
|
||||
}
|
||||
|
||||
/// The public, unauthenticated endpoint carrying the handshake.
|
||||
fn config_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/config")
|
||||
@@ -427,8 +621,8 @@ mod tests {
|
||||
#[test]
|
||||
fn urls_preserve_a_port_and_subpath() {
|
||||
assert_eq!(
|
||||
config_url("http://192.168.1.10:8000/thoughtsync"),
|
||||
"http://192.168.1.10:8000/thoughtsync/api/config"
|
||||
config_url("http://192.168.1.10:8000/inkwell"),
|
||||
"http://192.168.1.10:8000/inkwell/api/config"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+79
-17
@@ -17,12 +17,18 @@
|
||||
//! `docs/sync.md` for the policy that governs when those numbers move.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The sync wire protocol this client speaks.
|
||||
///
|
||||
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
|
||||
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
|
||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 4;
|
||||
/// v5 (#5168): files attached here upload, and removed attachments and previews are
|
||||
/// pushed. Additive: both go only to a server advertising `attachment_sync`.
|
||||
/// v6 (#5175): notes shared with this account, asked for with `shares`.
|
||||
/// v7 (#5176): this account's own pin, archive and position on a shared note, sent
|
||||
/// only to a server advertising `shared_state`.
|
||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 7;
|
||||
|
||||
/// The oldest server protocol this client can drive — the symmetric half of the
|
||||
/// server's `min_client_protocol_version`.
|
||||
@@ -46,7 +52,14 @@ pub const REQUIRED_FEATURES: &[&str] = &["notes", "labels"];
|
||||
/// Capabilities whose absence costs a feature but not the link. Listing these
|
||||
/// explicitly (rather than diffing against whatever the server happens to send) is
|
||||
/// what lets the UI name exactly what the user will be missing.
|
||||
pub const OPTIONAL_FEATURES: &[&str] = &["attachments", "tombstones", "revisions"];
|
||||
pub const OPTIONAL_FEATURES: &[&str] = &[
|
||||
"attachments",
|
||||
"tombstones",
|
||||
"revisions",
|
||||
"attachment_sync",
|
||||
"shares",
|
||||
"shared_state",
|
||||
];
|
||||
|
||||
/// The handshake fields of `GET /api/config`.
|
||||
///
|
||||
@@ -74,7 +87,7 @@ pub struct ServerInfo {
|
||||
}
|
||||
|
||||
impl ServerInfo {
|
||||
fn has_feature(&self, name: &str) -> bool {
|
||||
pub fn has_feature(&self, name: &str) -> bool {
|
||||
self.sync_features.iter().any(|f| f.as_str() == name)
|
||||
}
|
||||
|
||||
@@ -145,7 +158,7 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
|
||||
return incompatible(
|
||||
&format!(
|
||||
"This server requires client protocol v{floor} or newer; this app \
|
||||
speaks v{CLIENT_PROTOCOL_VERSION}. Update ThoughtSync."
|
||||
speaks v{CLIENT_PROTOCOL_VERSION}. Update Inkwell."
|
||||
),
|
||||
true,
|
||||
);
|
||||
@@ -172,16 +185,44 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
|
||||
}
|
||||
}
|
||||
|
||||
/// Who this client says it is, set once by the host application at startup.
|
||||
///
|
||||
/// THE CORE CANNOT KNOW THIS, and the value it used to invent was wrong twice. It
|
||||
/// was `inkwell-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into
|
||||
/// the desktop app AND the Android app — so every phone in the field announced
|
||||
/// itself as a desktop. The version was worse: `CARGO_PKG_VERSION` here is the
|
||||
/// version of the CORE crate, a number no build stamps and no user has ever seen,
|
||||
/// while the thing a reader of that header wants is the app's own build (note 3127
|
||||
/// §5 — with no version tags, the artifact's self-report is the only answer to
|
||||
/// "which build is this?").
|
||||
///
|
||||
/// So the host names itself. `OnceLock` because identity is fixed for the life of
|
||||
/// the process and a second caller should be ignored rather than race the first.
|
||||
static CLIENT_AGENT: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Name this client for the servers it talks to — `("inkwell-android", "2026.08.31.1204")`.
|
||||
///
|
||||
/// Call once at startup, before any sync. Calling twice is not an error and the
|
||||
/// first name wins; not calling it at all is visible in the header rather than
|
||||
/// silently plausible.
|
||||
pub fn set_client_agent(name: &str, version: &str) {
|
||||
let _ = CLIENT_AGENT.set(format!("{name}/{version}"));
|
||||
}
|
||||
|
||||
/// Headers this client puts on every request to a linked server, so the server can
|
||||
/// log or gate on client identity without a separate handshake round-trip.
|
||||
pub fn client_headers() -> [(&'static str, String); 2] {
|
||||
let agent = format!("thoughtsync-desktop/{}", env!("CARGO_PKG_VERSION"));
|
||||
// `unidentified/unknown`, never a plausible default. Nothing reads this header
|
||||
// today, which is exactly why a wrong value could sit in it for months: the
|
||||
// first person to look at a server log is the first person who could catch it,
|
||||
// and only if what they see is obviously a host that never introduced itself.
|
||||
let agent = CLIENT_AGENT
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "inkwell-unidentified/unknown".to_string());
|
||||
[
|
||||
("X-ThoughtSync-Client", agent),
|
||||
(
|
||||
"X-ThoughtSync-Protocol",
|
||||
CLIENT_PROTOCOL_VERSION.to_string(),
|
||||
),
|
||||
("X-Inkwell-Client", agent),
|
||||
("X-Inkwell-Protocol", CLIENT_PROTOCOL_VERSION.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -202,7 +243,7 @@ pub fn normalize_base_url(raw: &str) -> Option<String> {
|
||||
let with_scheme = match trimmed.split_once("://") {
|
||||
Some((scheme, rest)) => {
|
||||
// Anything that isn't HTTP(S) (ftp://, file://, a stray "foo://") can't
|
||||
// be a ThoughtSync server; reject rather than fail confusingly later.
|
||||
// be an Inkwell server; reject rather than fail confusingly later.
|
||||
let scheme = scheme.to_ascii_lowercase();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return None;
|
||||
@@ -227,7 +268,7 @@ mod tests {
|
||||
/// A server matching this client exactly, which each test then degrades.
|
||||
fn current_server() -> ServerInfo {
|
||||
ServerInfo {
|
||||
site_name: Some("ThoughtSync".into()),
|
||||
site_name: Some("Inkwell".into()),
|
||||
version: Some("0.1.0".into()),
|
||||
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
|
||||
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
|
||||
@@ -250,7 +291,7 @@ mod tests {
|
||||
fn server_without_protocol_fields_is_too_old() {
|
||||
// A pre-M10.6 server: /api/config parses, but carries no protocol block.
|
||||
let info = ServerInfo {
|
||||
site_name: Some("ThoughtSync".into()),
|
||||
site_name: Some("Inkwell".into()),
|
||||
version: Some("0.0.9".into()),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -360,13 +401,20 @@ mod tests {
|
||||
// must not break the handshake.
|
||||
// Versions come from the constants, not literals: this test is about unknown
|
||||
// FIELDS, and pinning the numbers made it fail the moment the protocol moved
|
||||
// to v2 — for a reason that has nothing to do with what it checks.
|
||||
// to v2 — for a reason that has nothing to do with what it checks. The feature
|
||||
// list likewise: a literal one went stale when `attachment_sync` was added.
|
||||
let features: Vec<&str> = REQUIRED_FEATURES
|
||||
.iter()
|
||||
.chain(OPTIONAL_FEATURES.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
let body = format!(
|
||||
r#"{{"site_name":"S","sync_protocol_version":{v},
|
||||
"min_client_protocol_version":{v},
|
||||
"sync_features":["notes","labels","attachments","tombstones","revisions"],
|
||||
"sync_features":{f},
|
||||
"some_future_field":{{"nested":true}}}}"#,
|
||||
v = CLIENT_PROTOCOL_VERSION,
|
||||
f = serde_json::to_string(&features).expect("features"),
|
||||
);
|
||||
let info: ServerInfo = serde_json::from_str(&body).expect("unknown fields are ignored");
|
||||
assert_eq!(evaluate(&info), Compatibility::Ok);
|
||||
@@ -374,10 +422,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn client_headers_identify_app_and_protocol() {
|
||||
// Sets the process-wide agent, which is why this test also owns the
|
||||
// assertion about it: a second test calling `set_client_agent` would race
|
||||
// this one for the OnceLock, and whichever lost would see the other's name.
|
||||
// One test, both branches, in order.
|
||||
assert!(
|
||||
client_headers()[0].1.starts_with("inkwell-unidentified/"),
|
||||
"a host that never introduced itself must say so"
|
||||
);
|
||||
|
||||
set_client_agent("inkwell-test", "2026.08.31.1204");
|
||||
let headers = client_headers();
|
||||
assert_eq!(headers[0].0, "X-ThoughtSync-Client");
|
||||
assert!(headers[0].1.starts_with("thoughtsync-desktop/"));
|
||||
assert_eq!(headers[0].0, "X-Inkwell-Client");
|
||||
assert_eq!(headers[0].1, "inkwell-test/2026.08.31.1204");
|
||||
assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string());
|
||||
|
||||
// First name wins — a second host cannot rename a running process.
|
||||
set_client_agent("inkwell-impostor", "0");
|
||||
assert_eq!(client_headers()[0].1, "inkwell-test/2026.08.31.1204");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+41
-12
@@ -13,7 +13,7 @@ use super::push;
|
||||
use super::state;
|
||||
use crate::local::Db;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SyncOutcome {
|
||||
pub push: push::PushSummary,
|
||||
pub pull: pull::PullSummary,
|
||||
@@ -38,8 +38,41 @@ pub async fn run_cycle(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> Result<SyncOutcome, String> {
|
||||
let push = push::run(db, base_url, token).await?;
|
||||
let pull = pull::run(db, blobs, base_url, token).await?;
|
||||
// What this server can do, asked before anything is sent: files attached here,
|
||||
// and removed attachments and previews, go only to a server advertising
|
||||
// `attachment_sync`. An older one keeps them queued on this device until it is
|
||||
// updated, rather than refusing each one on every cycle. Best-effort — an
|
||||
// unanswered probe reads as "not advertised", and the cycle carries on.
|
||||
let server = super::client::probe(base_url).await.ok().map(|p| p.server);
|
||||
let attachment_sync = server
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.has_feature("attachment_sync"));
|
||||
// Notes shared with this account come only from a server offering `shares`, and
|
||||
// an unanswered probe reads as "not offered", like `attachment_sync` above.
|
||||
let shares = server.as_ref().is_some_and(|s| s.has_feature("shares"));
|
||||
// A recipient's own pin, archive and order on a shared note go only to a server
|
||||
// that keeps them per person; an older one would apply them to nobody.
|
||||
let accepts = push::Accepts {
|
||||
attachment_sync,
|
||||
shared_state: server
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.has_feature("shared_state")),
|
||||
};
|
||||
|
||||
let push = push::run(db, blobs, base_url, token, accepts).await?;
|
||||
if shares {
|
||||
// After the push, so nothing unsent is waiting when the full pull lands.
|
||||
let level = if accepts.shared_state {
|
||||
state::SHARED_STATE
|
||||
} else {
|
||||
state::SHARES
|
||||
};
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
if state::begin_shares(&conn, level).map_err(|e| e.to_string())? {
|
||||
log::info!("the server shares more of notes now; pulling everything once");
|
||||
}
|
||||
}
|
||||
let pull = pull::run(db, blobs, base_url, token, shares).await?;
|
||||
|
||||
if pull.clobbered_dirty > 0 {
|
||||
// Push ran first and reported success, so nothing should still have been
|
||||
@@ -51,15 +84,11 @@ pub async fn run_cycle(
|
||||
);
|
||||
}
|
||||
|
||||
// While we're already talking to this server, re-read what it says about itself.
|
||||
// Today that's the trash-retention window the Trash view counts down against, and
|
||||
// it can change under us whenever an admin edits the setting. Best-effort on
|
||||
// purpose: a config blip must not fail a cycle whose actual work already
|
||||
// succeeded, and the stored value simply stays as it was.
|
||||
let retention = super::client::probe(base_url)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|p| p.server.trash_retention_days);
|
||||
// The same answer carries the trash-retention window the Trash view counts down
|
||||
// against, which can change whenever an admin edits the setting. Best-effort on
|
||||
// purpose: a config blip must not fail a cycle whose actual work succeeded, and
|
||||
// the stored value simply stays as it was.
|
||||
let retention = server.and_then(|s| s.trash_retention_days);
|
||||
|
||||
let status = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Talking to a ThoughtSync server — entirely opt-in.
|
||||
//! Talking to an Inkwell server — entirely opt-in.
|
||||
//!
|
||||
//! The app is local-first: `local` is the source of truth and everything works
|
||||
//! unlinked. Nothing in here runs until the user links a server.
|
||||
@@ -8,6 +8,7 @@
|
||||
//! - `client` — HTTP transport: the handshake call and device-token auth.
|
||||
//! - `state` — the persisted link record (server, token, change-feed cursor).
|
||||
//! - `engine` — one full cycle: push local changes, then pull the server's.
|
||||
//! - `sharing` — the Share dialog's calls, straight to the server (#5175).
|
||||
//!
|
||||
//! The UI surface that drives this lives in whichever client is wrapping the crate,
|
||||
//! not here.
|
||||
@@ -18,5 +19,6 @@ pub mod compat;
|
||||
pub mod engine;
|
||||
pub mod pull;
|
||||
pub mod push;
|
||||
pub mod sharing;
|
||||
pub mod state;
|
||||
pub mod wire;
|
||||
|
||||
+236
-16
@@ -149,6 +149,18 @@ pub fn apply_page(conn: &Connection, page: &wire::ChangesPage) -> rusqlite::Resu
|
||||
summary.notes_applied += 1;
|
||||
}
|
||||
|
||||
// After the notes, never before: a page can carry a note AND its revocation only
|
||||
// when the revocation is the newer of the two (sharing again deletes an older
|
||||
// one on the server), so the revocation is the one that has to win. Only a note
|
||||
// someone else owns can be revoked; this account's own are never touched.
|
||||
for id in &page.revoked {
|
||||
let removed = tx.execute(
|
||||
"DELETE FROM notes WHERE id = ?1 AND permission <> 'owner'",
|
||||
params![id],
|
||||
)?;
|
||||
summary.notes_deleted += removed;
|
||||
}
|
||||
|
||||
state::set_cursor(&tx, page.cursor)?;
|
||||
tx.commit()?;
|
||||
Ok(summary)
|
||||
@@ -239,23 +251,38 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
};
|
||||
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
|
||||
// never changes, and the server's copy is the same value anyway.
|
||||
// A server without `shares` sends no permission, and every note it sends is ours.
|
||||
let permission = match note.permission.as_deref() {
|
||||
Some("edit") => "edit",
|
||||
Some("view") => "view",
|
||||
_ => "owner",
|
||||
};
|
||||
let (shared_by_id, shared_by_name) = match ¬e.shared_by {
|
||||
Some(by) => (Some(by.id.as_str()), Some(by.display_name.as_str())),
|
||||
None => (None, None),
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, body, position, pinned, archived,
|
||||
trashed, remind_at, recurrence, created_at, updated_at,
|
||||
sync_revision, trashed_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0)
|
||||
sync_revision, trashed_at, dirty,
|
||||
permission, shared, shared_by_id, shared_by_name)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, ?13, ?14, ?15, ?16)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
body = excluded.body,
|
||||
position = excluded.position,
|
||||
pinned = excluded.pinned,
|
||||
archived = excluded.archived,
|
||||
trashed = excluded.trashed,
|
||||
remind_at = excluded.remind_at,
|
||||
recurrence = excluded.recurrence,
|
||||
updated_at = excluded.updated_at,
|
||||
sync_revision = excluded.sync_revision,
|
||||
trashed_at = excluded.trashed_at,
|
||||
dirty = 0",
|
||||
body = excluded.body,
|
||||
position = excluded.position,
|
||||
pinned = excluded.pinned,
|
||||
archived = excluded.archived,
|
||||
trashed = excluded.trashed,
|
||||
remind_at = excluded.remind_at,
|
||||
recurrence = excluded.recurrence,
|
||||
updated_at = excluded.updated_at,
|
||||
sync_revision = excluded.sync_revision,
|
||||
trashed_at = excluded.trashed_at,
|
||||
dirty = 0,
|
||||
permission = excluded.permission,
|
||||
shared = excluded.shared,
|
||||
shared_by_id = excluded.shared_by_id,
|
||||
shared_by_name = excluded.shared_by_name",
|
||||
params![
|
||||
note.id,
|
||||
note.body,
|
||||
@@ -269,6 +296,10 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
updated,
|
||||
note.sync_revision,
|
||||
trashed_at,
|
||||
permission,
|
||||
note.shared,
|
||||
shared_by_id,
|
||||
shared_by_name,
|
||||
],
|
||||
)?;
|
||||
|
||||
@@ -281,14 +312,41 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether this device removed the row and the server hasn't acknowledged it yet.
|
||||
/// Such a row is still on the server, so it is still in the feed — and putting it
|
||||
/// back would undo a removal that is only waiting for the next push.
|
||||
fn removed_here(conn: &Connection, entity: &str, id: &str) -> rusqlite::Result<bool> {
|
||||
let found: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM pending_deletes WHERE entity = ?1 AND id = ?2",
|
||||
params![entity, id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(found.is_some())
|
||||
}
|
||||
|
||||
fn replace_attachments(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
// Only rows the server already had are replaced. A file attached here and still
|
||||
// waiting to upload exists nowhere else yet, and deleting it would lose it; once
|
||||
// it has gone up, the server's copy arrives under the same id and takes its place.
|
||||
conn.execute(
|
||||
"DELETE FROM attachments WHERE note_id = ?1",
|
||||
"DELETE FROM attachments WHERE note_id = ?1 AND uploaded = 1",
|
||||
params![note.id],
|
||||
)?;
|
||||
for (index, att) in note.attachments.iter().enumerate() {
|
||||
if removed_here(conn, "attachment", &att.id)? {
|
||||
continue;
|
||||
}
|
||||
// The server listing an id this device is still waiting to upload means the
|
||||
// upload landed and only its reply was lost. The server's row replaces it.
|
||||
conn.execute(
|
||||
"DELETE FROM attachments WHERE id = ?1 AND uploaded = 0",
|
||||
params![att.id],
|
||||
)?;
|
||||
// The feed carries no explicit position for attachments — they arrive in
|
||||
// creation order, so the index preserves it.
|
||||
// creation order, so the index preserves it. A plain INSERT, so an id listed
|
||||
// twice fails the page rather than being quietly merged.
|
||||
conn.execute(
|
||||
"INSERT INTO attachments (id, note_id, url, filename, mime, size, sha256, position)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
@@ -313,6 +371,9 @@ fn replace_previews(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()
|
||||
params![note.id],
|
||||
)?;
|
||||
for (index, preview) in note.previews.iter().enumerate() {
|
||||
if removed_here(conn, "preview", &preview.id)? {
|
||||
continue;
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO link_previews (id, note_id, url, title, description, image_url,
|
||||
site_name, position)
|
||||
@@ -379,6 +440,7 @@ pub async fn run(
|
||||
blobs: &BlobStore,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
shares: bool,
|
||||
) -> Result<PullSummary, String> {
|
||||
let mut total = PullSummary::default();
|
||||
|
||||
@@ -388,7 +450,7 @@ pub async fn run(
|
||||
state::read(&conn).map_err(|e| e.to_string())?.last_cursor
|
||||
};
|
||||
|
||||
let page = client::fetch_changes(base_url, token, since).await?;
|
||||
let page = client::fetch_changes(base_url, token, since, shares).await?;
|
||||
|
||||
// Trust the data over the flag: a server that claims more pages without
|
||||
// advancing the cursor would spin this loop forever.
|
||||
@@ -475,6 +537,9 @@ mod tests {
|
||||
labels: vec![],
|
||||
attachments: vec![],
|
||||
previews: vec![],
|
||||
permission: None,
|
||||
shared: false,
|
||||
shared_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +560,7 @@ mod tests {
|
||||
labels,
|
||||
cursor,
|
||||
has_more: false,
|
||||
revoked: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +601,76 @@ mod tests {
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
|
||||
}
|
||||
|
||||
fn shared_note(id: &str, revision: i64, permission: &str) -> wire::Note {
|
||||
let mut n = note(id, revision);
|
||||
n.permission = Some(permission.into());
|
||||
n.shared = true;
|
||||
n.shared_by = Some(wire::SharedBy {
|
||||
id: "u-owner".into(),
|
||||
display_name: "Robin".into(),
|
||||
});
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_note_lands_saying_who_shared_it_and_how() {
|
||||
let conn = db();
|
||||
apply_page(&conn, &page(vec![shared_note("n1", 1, "edit")], vec![], 1)).expect("apply");
|
||||
let held: (String, i64, String) = conn
|
||||
.query_row(
|
||||
"SELECT permission, shared, shared_by_name FROM notes WHERE id = 'n1'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.expect("row");
|
||||
assert_eq!(held, ("edit".to_string(), 1, "Robin".to_string()));
|
||||
|
||||
// A server without shares sends no permission: the note is this account's own.
|
||||
apply_page(&conn, &page(vec![note("n2", 2)], vec![], 2)).expect("apply");
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM notes WHERE id = 'n2' AND permission = 'owner'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_revoked_note_leaves_and_an_owned_one_never_does() {
|
||||
let conn = db();
|
||||
apply_page(
|
||||
&conn,
|
||||
&page(
|
||||
vec![shared_note("theirs", 1, "view"), note("mine", 2)],
|
||||
vec![],
|
||||
2,
|
||||
),
|
||||
)
|
||||
.expect("apply");
|
||||
let mut revoked = page(vec![], vec![], 3);
|
||||
revoked.revoked = vec!["theirs".into(), "mine".into(), "unknown".into()];
|
||||
let summary = apply_page(&conn, &revoked).expect("apply");
|
||||
assert_eq!(summary.notes_deleted, 1);
|
||||
let left: Vec<String> = {
|
||||
let mut stmt = conn.prepare("SELECT id FROM notes").unwrap();
|
||||
let rows = stmt.query_map([], |r| r.get(0)).unwrap();
|
||||
rows.collect::<rusqlite::Result<_>>().unwrap()
|
||||
};
|
||||
assert_eq!(left, ["mine"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_revocation_beside_its_note_in_one_page_wins() {
|
||||
// The server deletes an older revocation when it shares again, so a page holds
|
||||
// both only when the revocation is the newer: the note must not survive it.
|
||||
let conn = db();
|
||||
let mut both = page(vec![shared_note("n1", 4, "view")], vec![], 5);
|
||||
both.revoked = vec!["n1".into()];
|
||||
apply_page(&conn, &both).expect("apply");
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trashed_is_not_a_tombstone() {
|
||||
// `trashed` is ordinary state that keeps syncing; only `purged_at` deletes.
|
||||
@@ -778,4 +914,88 @@ mod tests {
|
||||
assert_eq!(state::read(&conn).expect("state").last_cursor, 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
|
||||
}
|
||||
|
||||
fn preview(id: &str) -> wire::Preview {
|
||||
wire::Preview {
|
||||
id: id.to_string(),
|
||||
url: "https://example.com/".into(),
|
||||
title: None,
|
||||
description: None,
|
||||
image_url: None,
|
||||
site_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn queued_upload(conn: &Connection, id: &str, note_id: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO attachments (id, note_id, url, uploaded) VALUES (?1, ?2, '/x', 0)",
|
||||
params![id, note_id],
|
||||
)
|
||||
.expect("queued upload");
|
||||
}
|
||||
|
||||
fn attachment_ids(conn: &Connection) -> Vec<String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM attachments ORDER BY id")
|
||||
.expect("prepare");
|
||||
let rows = stmt.query_map([], |r| r.get(0)).expect("query");
|
||||
rows.collect::<rusqlite::Result<_>>().expect("rows")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pull_keeps_a_file_still_waiting_to_upload() {
|
||||
// It exists only on this device until push sends it; a pull that replaced the
|
||||
// note's attachments wholesale would delete the only copy.
|
||||
let conn = db();
|
||||
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
|
||||
queued_upload(&conn, "mine", "n1");
|
||||
|
||||
let mut changed = note("n1", 2);
|
||||
changed.attachments = vec![attachment("theirs")];
|
||||
apply_page(&conn, &page(vec![changed], vec![], 2)).expect("apply");
|
||||
|
||||
assert_eq!(attachment_ids(&conn), vec!["mine", "theirs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_servers_copy_takes_over_from_an_upload_whose_reply_was_lost() {
|
||||
let conn = db();
|
||||
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
|
||||
queued_upload(&conn, "a1", "n1");
|
||||
|
||||
let mut listed = note("n1", 2);
|
||||
listed.attachments = vec![attachment("a1")];
|
||||
apply_page(&conn, &page(vec![listed], vec![], 2)).expect("apply");
|
||||
|
||||
assert_eq!(attachment_ids(&conn), vec!["a1"]);
|
||||
let uploaded: bool = conn
|
||||
.query_row(
|
||||
"SELECT uploaded FROM attachments WHERE id = 'a1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("row");
|
||||
assert!(uploaded, "not sent a second time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removal_waiting_to_be_pushed_is_not_undone_by_a_pull() {
|
||||
let conn = db();
|
||||
let mut first = note("n1", 1);
|
||||
first.attachments = vec![attachment("a1")];
|
||||
first.previews = vec![preview("p1")];
|
||||
apply_page(&conn, &page(vec![first], vec![], 1)).expect("apply");
|
||||
|
||||
crate::local::store::delete_attachment(&conn, "n1", "a1").expect("remove");
|
||||
crate::local::store::delete_preview(&conn, "n1", "p1").expect("dismiss");
|
||||
|
||||
// The server hasn't heard yet, so its copy of the note still lists both.
|
||||
let mut stale = note("n1", 2);
|
||||
stale.attachments = vec![attachment("a1")];
|
||||
stale.previews = vec![preview("p1")];
|
||||
apply_page(&conn, &page(vec![stale], vec![], 2)).expect("apply");
|
||||
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM link_previews"), 0);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user