diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3b69c5c7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Contract fixtures are hashed byte for byte by the TypeScript, Go server, and +# TUI consumers, so they must check out with LF on every platform, including +# Windows runners whose Git defaults to CRLF conversion. +packages/bridge-contract/fixtures/** text eol=lf diff --git a/.github/workflows/boundary-artifact-release.yml b/.github/workflows/boundary-artifact-release.yml new file mode 100644 index 00000000..284beca2 --- /dev/null +++ b/.github/workflows/boundary-artifact-release.yml @@ -0,0 +1,67 @@ +name: Prepare boundary artifact release +on: + workflow_dispatch: + inputs: + artifact: + type: choice + options: [core, web, viewer] + required: true + source_commit: + description: Reviewed full source commit SHA + type: string + required: true +permissions: + contents: read +concurrency: + group: boundary-release-${{ inputs.artifact }} + cancel-in-progress: false +jobs: + build: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.prepare.outputs.tag }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_commit }} + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run test:run + - run: npm run test:web-artifact + - if: inputs.artifact == 'core' + run: npm run test:app-core-package && npm run test:app-core-browser + - if: inputs.artifact == 'core' + env: + ZEN_CORE_VITE_VERSION: 8.2.2 + run: npm run test:app-core-package && npm run test:app-core-browser + - id: prepare + env: + ARTIFACT: ${{ inputs.artifact }} + APPROVED_SOURCE: ${{ inputs.source_commit }} + run: node tooling/scripts/prepare-boundary-release.mjs "$ARTIFACT" + - uses: actions/upload-artifact@v4 + with: + name: boundary-release + path: ${{ steps.prepare.outputs.directory }} + if-no-files-found: error + draft: + needs: build + runs-on: ubuntu-latest + environment: boundary-artifacts + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: boundary-release + path: release + - env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.build.outputs.tag }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + run: gh release create "$TAG" release/* --target "$SOURCE_COMMIT" --title "$TAG" --draft --prerelease --notes "Immutable boundary artifacts. Validate consumer pins before publishing this draft." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc614a7a..2ea32f3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,21 +52,48 @@ jobs: node-version: 22 cache: npm - - name: Set up Go - uses: actions/setup-go@v6 - with: - # Pin a modern Go instead of go.mod's `go 1.22`: the 1.22 macOS - # linker omits the LC_UUID load command, which the updated - # macos-latest dyld now rejects ("missing LC_UUID", abort trap) when - # launching `go test` binaries. go.mod stays at 1.22 (its real - # minimum), so Nix/release builds are unaffected. - go-version: stable - cache-dependency-path: apps/server/go.sum - - name: Install dependencies run: npm ci - - name: Typecheck and build app + - name: Verify terminal artifacts and legacy launchers + if: matrix.os != 'windows-latest' + run: node --test tooling/scripts/terminal-artifact.test.mjs tooling/scripts/terminal-launcher.test.mjs + + - name: Verify standalone shared packages + run: npm run test:shared-packages + + - name: Verify isolated editor package and assets + if: matrix.os == 'ubuntu-latest' + run: npm run test:app-core-package + + - name: Exercise the installed editor in Chrome + if: matrix.os == 'ubuntu-latest' + run: npm run test:app-core-browser + + - name: Verify the editor with the mobile Vite version + if: matrix.os == 'ubuntu-latest' env: - GOCACHE: ${{ runner.temp }}/go-build-cache + ZEN_CORE_VITE_VERSION: 8.2.2 + run: npm run test:app-core-package + + - name: Exercise the Vite 8 editor in Chrome + if: matrix.os == 'ubuntu-latest' + run: npm run test:app-core-browser + + - name: Collect browser evidence + if: always() && matrix.os == 'ubuntu-latest' + run: node tooling/scripts/collect-app-core-evidence.mjs "$RUNNER_TEMP/app-core-browser-evidence" + + - name: Retain browser evidence + if: always() && matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: app-core-browser-evidence + path: ${{ runner.temp }}/app-core-browser-evidence + if-no-files-found: ignore + + - name: Verify browser asset build lock + run: npm run test:web-dist-lock + + - name: Typecheck and build app run: npm run build:prod diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 4da645e1..00000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Publish Docker image - -on: - push: - tags: - - "v*" - workflow_dispatch: - inputs: - tag: - description: "Extra tag to publish (in addition to latest), e.g. 2.0.1" - required: false - type: string - -permissions: - contents: read - -concurrency: - group: docker-publish-${{ github.ref }} - cancel-in-progress: false - -env: - IMAGE: adibhanna/zennotes - -jobs: - publish: - name: Build and push multi-arch image - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Derive image tags and labels - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index d98fbfce..2067d579 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -1,8 +1,9 @@ name: Nix build -# Builds AND validates the flake packages on a Nix runner whenever the Nix -# packaging changes, so the prebuilt-desktop wrapper and the server package are -# verified in CI (the maintainers don't have a Nix machine to test on locally). +# Builds AND validates the flake package on a Nix runner whenever the Nix +# packaging changes, so the prebuilt-desktop wrapper is verified in CI (the +# maintainers don't have a Nix machine to test on locally). The server's Nix +# build lives in ZenNotes/znserver. on: push: @@ -32,7 +33,6 @@ jobs: - name: Build packages run: | nix build --fallback --print-build-logs .#zennotes-desktop -o result-desktop - nix build --fallback --print-build-logs .#zennotes-server -o result-server - name: Validate desktop package run: | diff --git a/.github/workflows/nix-update.yml b/.github/workflows/nix-update.yml index b79a5313..4745f6c3 100644 --- a/.github/workflows/nix-update.yml +++ b/.github/workflows/nix-update.yml @@ -41,7 +41,6 @@ jobs: run: | set -euo pipefail DATA=packaging/nix/release-data.json - FAKE="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" # 1) Source hash — the fetchFromGitHub tree for tag v$V. SRC_HASH=$(nix run --fallback nixpkgs#nix-prefetch-github -- ZenNotes zennotes --rev "v$V" | jq -r '.hash // .sha256') @@ -59,33 +58,15 @@ jobs: "https://github.com/ZenNotes/zennotes/releases/download/v$V/ZenNotes-$V-linux-x64.tar.gz" | jq -r '.hash') echo "desktopHash = $DESKTOP_HASH" - # Write version + source + npm + desktop now; leave vendorHash fake so the - # Go build surfaces the real one. - jq --arg v "$V" --arg h "$SRC_HASH" --arg n "$NPM_HASH" --arg d "$DESKTOP_HASH" --arg f "$FAKE" \ - '.version=$v | .hash=$h | .npmDepsHash=$n | .desktopHash=$d | .vendorHash=$f' "$DATA" > "$DATA.tmp" - mv "$DATA.tmp" "$DATA" - - # 3) vendorHash — build the server; the Go vendor fixed-output derivation - # reports the real hash as a mismatch against the fake one. - set +e - nix build --fallback .#zennotes-server --no-link 2> build.log - set -e - VENDOR_HASH=$(grep -oE 'got:[[:space:]]+sha256-[A-Za-z0-9+/=]+' build.log \ - | grep -oE 'sha256-[A-Za-z0-9+/=]+' | head -1 || true) - if [ -z "$VENDOR_HASH" ]; then - echo "::error::Could not extract vendorHash from the build output." - cat build.log - exit 1 - fi - echo "vendorHash = $VENDOR_HASH" - jq --arg vh "$VENDOR_HASH" '.vendorHash=$vh' "$DATA" > "$DATA.tmp" + jq --arg v "$V" --arg h "$SRC_HASH" --arg n "$NPM_HASH" --arg d "$DESKTOP_HASH" \ + '.version=$v | .hash=$h | .npmDepsHash=$n | .desktopHash=$d' "$DATA" > "$DATA.tmp" mv "$DATA.tmp" "$DATA" echo "=== updated release-data.json ===" cat "$DATA" - name: Verify the packages build with the new hashes - run: nix build --fallback .#zennotes-desktop .#zennotes-server --no-link --print-build-logs + run: nix build --fallback .#zennotes-desktop --no-link --print-build-logs - name: Open a PR with the update uses: peter-evans/create-pull-request@v6 @@ -100,6 +81,5 @@ jobs: - `version` - `hash` (source) — `nix-prefetch-github` - `npmDepsHash` — `prefetch-npm-deps` - - `vendorHash` — Go fixed-output build - Verified with `nix build .#zennotes-desktop .#zennotes-server`. + Verified with `nix build .#zennotes-desktop`. diff --git a/.github/workflows/share-viewer-artifact.yml b/.github/workflows/share-viewer-artifact.yml new file mode 100644 index 00000000..d46b0bc9 --- /dev/null +++ b/.github/workflows/share-viewer-artifact.yml @@ -0,0 +1,34 @@ +name: Public share viewer artifact + +on: + workflow_dispatch: + pull_request: + paths: + - 'apps/share-viewer/**' + - 'packages/**' + - 'tooling/scripts/**' + - 'package*.json' + - 'tsconfig.base.json' + - 'LICENSE' + - '.github/workflows/share-viewer-artifact.yml' + +permissions: + contents: read + +jobs: + candidate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: node --test tooling/scripts/pack-web-artifact.test.mjs + - run: npm run pack:share-viewer + - uses: actions/upload-artifact@v4 + with: + name: share-viewer-candidate + path: dist/viewer-artifacts/* + if-no-files-found: error diff --git a/.github/workflows/web-artifact.yml b/.github/workflows/web-artifact.yml new file mode 100644 index 00000000..410a76d1 --- /dev/null +++ b/.github/workflows/web-artifact.yml @@ -0,0 +1,34 @@ +name: Self-hosted web artifact boundary + +on: + workflow_dispatch: + pull_request: + paths: + - 'apps/web/**' + - 'packages/**' + - 'tooling/scripts/**' + - 'package*.json' + - 'tsconfig.base.json' + - 'LICENSE' + - '.github/workflows/web-artifact.yml' + +permissions: + contents: read + +jobs: + browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:web-artifact + - run: npm run artifact:web + - uses: actions/upload-artifact@v4 + with: + name: self-hosted-web-candidate + path: dist/web-artifacts/* + if-no-files-found: error diff --git a/.gitignore b/.gitignore index aea56221..33e05448 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,8 @@ apps/web/dist # The bare `dist` above does not cover these suffixed names. dist.stage-* dist.retired-* -apps/server/web/.web-dist.lock -apps/server/bin +apps/web/.web-dist.lock +dist/server-binaries .DS_Store *.log .env @@ -28,3 +28,6 @@ result result-* # Release notes / launch copy are kept local only, never committed. docs/releases/ +# Verified native CLI artifacts; release pins are tracked separately. +apps/desktop/build/terminal/ +apps/desktop/build/terminal.stage-* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e7a01b4..271f942d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,11 +23,11 @@ npm run dev Useful scripts: -- `npm run dev` — run the desktop app with hot reload (`dev:web`, `dev:server`, and `dev:web-stack` cover the web client and Go server) +- `npm run dev` — run the desktop app with hot reload (`dev:web`, `dev:server`, and `dev:web-stack` cover the web client and the pinned Go server release) - `npm run typecheck` — TypeScript across every workspace - `npm run test:run` — the full test suite, non-interactive (`npm test` is the watch variant) -- `npm run build` — production build of the web client, desktop app, and Go server -- `cd apps/server && go test ./...` — the Go server's own tests +- `npm run build` — production build of the web client and desktop app +- The Go server and its tests live in [ZenNotes/znserver](https://github.com/ZenNotes/znserver) There is no lint step; match the style of the surrounding code (Prettier is available if a file you touched is already formatted with it). diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 945ccb5b..00000000 --- a/Dockerfile +++ /dev/null @@ -1,77 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -# ZenNotes self-hosted build from the monorepo. -# -# Stages: -# 1. web-build -> npm workspace install + Vite build for apps/web -# 2. server-build -> Go build for apps/server with the web bundle embedded -# 3. runtime -> minimal image with only the server binary - -# Base images pinned by digest. Refresh with Renovate/Dependabot or by -# running `docker inspect --format='{{index .RepoDigests 0}}' ` -# after a deliberate `docker pull` of the desired floating tag. -# web-build emits a platform-agnostic static bundle, so always run it on the -# native build platform (never under emulation) regardless of the target arch. -FROM --platform=$BUILDPLATFORM node:22-alpine@sha256:8ea2348b068a9544dae7317b4f3aafcdc032df1647bb7d768a05a5cad1a7683f AS web-build -WORKDIR /app - -COPY package.json package-lock.json turbo.json tsconfig.base.json tsconfig.json tailwind.config.js postcss.config.js ./ -COPY apps/web/package.json apps/web/package.json -COPY packages/app-core/package.json packages/app-core/package.json -COPY packages/bridge-contract/package.json packages/bridge-contract/package.json -COPY packages/shared-domain/package.json packages/shared-domain/package.json -COPY packages/shared-ui/package.json packages/shared-ui/package.json -COPY apps/desktop/package.json apps/desktop/package.json -COPY apps/server/package.json apps/server/package.json - -RUN npm ci --no-audit --no-fund --loglevel=error - -COPY apps apps -COPY packages packages -# The renderer configs import shared Vite plugins from tooling/vite (the -# Harper wasm asset resolver); without this copy the web build cannot resolve them. -COPY tooling/vite tooling/vite - -RUN npm run build --workspace @zennotes/web - -# Run the Go toolchain on the native build platform and cross-compile to the -# target arch (CGO is off, so this is a fast pure-Go cross-build — no QEMU). -FROM --platform=$BUILDPLATFORM golang:1.26-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648 AS server-build -WORKDIR /app - -COPY apps/server/go.mod apps/server/go.sum ./apps/server/ -WORKDIR /app/apps/server -RUN go mod download - -WORKDIR /app -COPY apps/server apps/server -COPY --from=web-build /app/apps/web/dist/ /app/apps/server/web/dist/ - -# TARGETARCH is provided by buildx (e.g. amd64, arm64) for the image being built. -ARG TARGETARCH -ENV CGO_ENABLED=0 \ - GOOS=linux \ - GOARCH=$TARGETARCH \ - GOFLAGS=-trimpath - -WORKDIR /app/apps/server -RUN go build -ldflags="-s -w" -o /out/zennotes-server ./cmd/zennotes-server - -FROM scratch -LABEL org.opencontainers.image.title="ZenNotes" \ - org.opencontainers.image.description="Self-hosted ZenNotes web/server bundle from the monorepo." \ - org.opencontainers.image.source="https://github.com/ZenNotes/zennotes" - -COPY --from=server-build /out/zennotes-server /zennotes-server - -ENV ZENNOTES_BIND=0.0.0.0:7878 \ - ZENNOTES_CONFIG_PATH=/data/server.json \ - ZENNOTES_DEFAULT_VAULT_PATH=/workspace \ - ZENNOTES_BROWSE_ROOTS=/workspace - -USER 65532:65532 - -EXPOSE 7878 -VOLUME ["/workspace", "/data"] - -ENTRYPOINT ["/zennotes-server"] diff --git a/Makefile b/Makefile index 6aee6b6e..c22352f9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -IMAGE ?= zennotes-selfhosted:local +IMAGE ?= adibhanna/zennotes:latest PORT ?= 7878 CONTENT_ROOT ?= ./vault DATA ?= ./data @@ -8,7 +8,7 @@ COMPOSE := $(shell docker compose version >/dev/null 2>&1 && echo "docker compos OPEN_BROWSER := $(shell command -v open 2>/dev/null || command -v xdg-open 2>/dev/null) .PHONY: help install dev desktop web-dev server-dev web-stack \ - build desktop-build web-build server-build \ + build desktop-build web-build \ up down restart logs status open rebuild nuke clean help: @@ -19,30 +19,30 @@ help: @echo " Local development" @echo " make desktop — run the Electron desktop app in dev mode" @echo " make web-dev — run the Vite web client in dev mode" - @echo " make server-dev — run the Go server in dev mode" + @echo " make server-dev — run the pinned ZenNotes/znserver release (or a checkout via ZENNOTES_SERVER_DIR)" @echo " make web-stack — run server + web dev together" @echo "" @echo " Local builds" @echo " make build — build the full monorepo" @echo " make desktop-build — build the Electron desktop app" @echo " make web-build — build apps/web" - @echo " make server-build — build apps/server with the latest embedded web bundle" @echo "" @echo " Docker" - @echo " make up — build and start the self-hosted server" + @echo " make up — start the self-hosted server from the published image" @echo " make down — stop the container" @echo " make restart — restart the container" @echo " make logs — follow logs" @echo " make status — show compose status" @echo " make open — open the app in your browser" - @echo " make rebuild — force a full rebuild" - @echo " make nuke — tear down and remove local image/build output" - @echo " make clean — remove local web/server build output" + @echo " make rebuild — pull the newest image and restart" + @echo " make nuke — tear down and remove the image, data, and build output" + @echo " make clean — remove local web build output and downloaded server binaries" @echo "" @echo " Useful Docker vars" @echo " CONTENT_ROOT=~/iCloud Drive/Obsidian — host folder used as the live vault root" @echo " PORT=7878 — host port" @echo " ALLOW_INSECURE_NOAUTH=1 — opt out of generated auth token (not recommended)" + @echo " IMAGE=adibhanna/zennotes:2.50.5 — pin a published server image (default: latest)" @echo "" install: @@ -96,7 +96,7 @@ up: ZENNOTES_HOST_DATA="$$ABS_DATA" \ ZENNOTES_CONTAINER_UID="$$(id -u)" \ ZENNOTES_CONTAINER_GID="$$(id -g)" \ - $(COMPOSE) up --build -d + $(COMPOSE) up -d @printf "\nZenNotes is running at $(APP_URL)\n\n" ifneq ($(ALLOW_INSECURE_NOAUTH),1) @printf "Auth token: $(DATA)/auth-token\n\n" @@ -149,15 +149,12 @@ rebuild: ZENNOTES_HOST_DATA="$$ABS_DATA" \ ZENNOTES_CONTAINER_UID="$$(id -u)" \ ZENNOTES_CONTAINER_GID="$$(id -g)" \ - $(COMPOSE) build --no-cache + $(COMPOSE) pull @$(MAKE) --no-print-directory up nuke: - @$(COMPOSE) down --rmi local --volumes || true - @rm -rf apps/web/dist apps/server/bin apps/server/web/dist $(DATA) - -server-build: web-build - npm run build --workspace @zennotes/server + @$(COMPOSE) down --rmi all --volumes || true + @rm -rf apps/web/dist dist/server-binaries $(DATA) clean: - rm -rf apps/web/dist apps/server/bin apps/server/web/dist + rm -rf apps/web/dist dist/server-binaries diff --git a/README.md b/README.md index 42b68c59..4f961c70 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,6 @@ ZenNotes now uses a single monorepo. apps/ desktop/ Electron shell, preload, updater, packaging web/ Vite/PWA shell and HTTP bridge - server/ Go server for self-hosted and hosted deployments packages/ app-core/ Shared React application and renderer logic bridge-contract/ Typed runtime contract between UI and host @@ -345,6 +344,13 @@ make web-dev ### Go server +The server lives in its own repository, +[ZenNotes/znserver](https://github.com/ZenNotes/znserver). For browser work +you do not need it checked out: `npm run dev:server` downloads the release +pinned in `tooling/server-release.json`, verifies its checksum, and runs it. +To work on the server itself, point `ZENNOTES_SERVER_DIR` at a znserver +checkout and the same command runs it with `go run`. + ```bash npm run dev:server ``` @@ -371,7 +377,7 @@ Important dev note: - the browser app and the Go server are separate processes in dev mode - frontend-only changes usually need only the web dev server -- backend changes need the Go server restarted +- backend changes happen in ZenNotes/znserver and need that server restarted - if the web client is newer than the running server, ZenNotes now shows a clearer error instead of raw 404 noise for newer API flows like the vault picker ## Root scripts @@ -405,26 +411,29 @@ The root `Makefile` provides a simpler interface: | `make install` | Install workspace dependencies | | `make desktop` | Run the Electron app in dev mode | | `make web-dev` | Run the web client | -| `make server-dev` | Run the Go server | +| `make server-dev` | Run the pinned Go server release (or a checkout) | | `make web-stack` | Run web + server together | | `make build` | Build the full monorepo | | `make desktop-build` | Build the Electron app | | `make web-build` | Build `apps/web` | -| `make server-build` | Build `apps/server` with the latest embedded web bundle | -| `make up` | Build and start the self-hosted Docker stack | +| `make up` | Start the self-hosted Docker stack from the published image | | `make down` | Stop the Docker stack | | `make restart` | Restart the Docker stack | | `make logs` | Follow Docker logs | | `make status` | Show Docker status | | `make open` | Open the self-hosted app in a browser | -| `make rebuild` | Force a full Docker rebuild | -| `make nuke` | Remove local Docker image/build output | -| `make clean` | Remove local web/server build output | +| `make rebuild` | Pull the newest image and restart | +| `make nuke` | Remove the Docker image, data, and build output | +| `make clean` | Remove web build output and downloaded server binaries | Run `make help` to print the same summary. ## Self-hosting with Docker +The image, `adibhanna/zennotes`, is built and published from the server's own +repository, [ZenNotes/znserver](https://github.com/ZenNotes/znserver). This +repository only ships the Compose file and Makefile that run it. + ### Start the self-hosted app ```bash @@ -481,7 +490,7 @@ Useful variables: - `CONTENT_ROOT`: host folder used as the live vault root - `DATA`: host directory used for persisted server config - `PORT`: published host port -- `IMAGE`: Docker image tag +- `IMAGE`: Docker image to run (default `adibhanna/zennotes:latest`; pin with `IMAGE=adibhanna/zennotes:2.50.5`) - `ALLOW_INSECURE_NOAUTH`: disable the default auth requirement ### Docker browse model diff --git a/apps/desktop/build/after-pack.js b/apps/desktop/build/after-pack.js index 707336b3..d256cb5f 100644 --- a/apps/desktop/build/after-pack.js +++ b/apps/desktop/build/after-pack.js @@ -11,6 +11,7 @@ // package. Cheap — these are a handful of small PNGs plus a .desktop file. const fs = require('node:fs') const path = require('node:path') +const { pathToFileURL } = require('node:url') async function rewriteAsFreshFiles(dir) { let entries @@ -36,6 +37,18 @@ async function rewriteAsFreshFiles(dir) { } exports.default = async function afterPack(context) { - if (context.electronPlatformName !== 'linux') return + const platform = context.electronPlatformName + if (platform === 'darwin' || platform === 'linux') { + const arch = { 1: 'x64', 3: 'arm64' }[context.arch] + if (!arch) throw new Error(`Unsupported terminal package architecture: ${context.arch}`) + const localDirectory = process.env.ZENNOTES_TERMINAL_ARTIFACT_DIR + const allowLocal = process.env.ZENNOTES_ALLOW_LOCAL_TERMINAL === '1' && !process.env.CI + const { stageTerminalArtifact } = await import(pathToFileURL(path.resolve(__dirname, '../../../tooling/scripts/terminal-artifact.mjs')).href) + const resources = platform === 'darwin' + ? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources') + : path.join(context.appOutDir, 'resources') + await stageTerminalArtifact({ platform, arch, localDirectory, allowLocal, output: path.join(resources, 'terminal') }) + } + if (platform !== 'linux') return await rewriteAsFreshFiles(path.join(context.appOutDir, 'resources', 'arch-extras')) } diff --git a/apps/desktop/build/zen b/apps/desktop/build/zen index c5c49beb..0ccf5ea2 100755 --- a/apps/desktop/build/zen +++ b/apps/desktop/build/zen @@ -1,22 +1,7 @@ #!/bin/sh -# zen — POSIX wrapper for the ZenNotes CLI. -# -# Installed by ZenNotes (Settings -> CLI -> Install) as a symlink at -# /usr/local/bin/zen pointing at this file inside the .app bundle. When -# invoked, we set ELECTRON_RUN_AS_NODE=1 and exec the packaged Electron -# binary against the bundled cli.js, so the user does not need a system -# Node install. -# -# Layout assumed (macOS .app bundle): -# /Contents/Resources/zen (this script) -# /Contents/Resources/cli.js (bundled JS, ESM) -# /Contents/MacOS/ (Electron binary) -# -# On Linux AppImage / unpacked builds the layout differs slightly: -# /resources/zen -# /resources/cli.js -# / -# We resolve relative to whichever pattern matches. +# Stable compatibility path for existing desktop-installed zn shortcuts. +# Use the persistent Go installation after desktop verifies and activates it. +# Node remains available before activation and for explicit rollback. set -e @@ -32,12 +17,6 @@ while [ -h "$SOURCE" ]; do done SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)" -CLI_JS="$SCRIPT_DIR/cli.js" -if [ ! -f "$CLI_JS" ]; then - echo "zen: cli.js not found next to wrapper at $CLI_JS" >&2 - exit 2 -fi - # Find the Electron executable. Try the macOS layout first (script lives # in Contents/Resources, executable in Contents/MacOS), then the Linux # layout (script in resources/, executable in the parent). @@ -60,8 +39,34 @@ if [ -z "$ELECTRON" ]; then fi done fi +case "${ZENNOTES_CLI_ENGINE:-go}" in + go) + : "${ZENNOTES_WORKSPACE_SOURCE:=app}" + export ZENNOTES_WORKSPACE_SOURCE + if [ -d "$APP_BUNDLE/MacOS" ]; then + DEFAULT_USER_DATA="$HOME/Library/Application Support/ZenNotes" + else + DEFAULT_USER_DATA="${XDG_CONFIG_HOME:-$HOME/.config}/ZenNotes" + fi + USER_DATA="${ZENNOTES_USER_DATA_PATH:-${ZENNOTES_CONFIG_DIR:-$DEFAULT_USER_DATA}}" + if [ -z "${ZENNOTES_APP_PATH:-}" ] && [ -n "$ELECTRON" ]; then + ZENNOTES_APP_PATH="${APPIMAGE:-$ELECTRON}" + export ZENNOTES_APP_PATH + fi + if [ -x "$USER_DATA/cli/zn" ]; then exec "$USER_DATA/cli/zn" "$@"; fi + ;; + legacy) ;; + *) echo "zn: ZENNOTES_CLI_ENGINE must be go or legacy." >&2; exit 2 ;; +esac + if [ -z "$ELECTRON" ]; then - echo "zen: could not locate the ZenNotes Electron binary near $APP_BUNDLE" >&2 + echo "zn: could not locate the ZenNotes Electron binary near $APP_BUNDLE" >&2 + exit 2 +fi + +CLI_JS="$SCRIPT_DIR/cli.js" +if [ ! -f "$CLI_JS" ]; then + echo "zn: cli.js not found next to wrapper at $CLI_JS" >&2 exit 2 fi diff --git a/apps/desktop/build/zennotes b/apps/desktop/build/zennotes new file mode 100755 index 00000000..d27248c8 --- /dev/null +++ b/apps/desktop/build/zennotes @@ -0,0 +1,3 @@ +#!/bin/sh +# Lowercase desktop launcher for official Linux packages. +exec /opt/ZenNotes/ZenNotes "$@" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b459fbe3..e35fe86a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.50.4", + "version": "2.51.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", @@ -299,7 +299,14 @@ "compression": "xz" }, "pacman": { - "compression": "xz" + "compression": "xz", + "fpm": [ + "--provides", + "zennotes", + "--conflicts", + "zennotes-bin", + "build/zennotes=/usr/bin/zennotes" + ] } } } diff --git a/apps/desktop/src/main/cli-install.test.ts b/apps/desktop/src/main/cli-install.test.ts index 57754551..15a3f3fa 100644 --- a/apps/desktop/src/main/cli-install.test.ts +++ b/apps/desktop/src/main/cli-install.test.ts @@ -1,12 +1,4 @@ -import { - lstat, - mkdir, - mkdtemp, - readlink, - rm, - symlink, - writeFile -} from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, readlink, rm, symlink, writeFile } from 'node:fs/promises' import { promises as fsPromises } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -102,9 +94,7 @@ describe('removeManagedLinks — migrate off `zen`, spare foreign (#126)', () => expect(await linkExists(path.join(bin, 'zen'))).toBe(false) expect(await linkExists(path.join(bin, 'zn'))).toBe(false) - expect(removed).toEqual( - expect.arrayContaining([path.join(bin, 'zen'), path.join(bin, 'zn')]) - ) + expect(removed).toEqual(expect.arrayContaining([path.join(bin, 'zen'), path.join(bin, 'zn')])) }) it('never removes a foreign `zen` (e.g. Zen Browser) or a real file', async () => { @@ -189,7 +179,7 @@ describe('migrateLegacyCliLink — heal pre-2.10 installs on launch', () => { }) }) -describe('PATH detection follows the user\'s shell, not the app\'s (#528)', () => { +describe("PATH detection follows the user's shell, not the app's (#528)", () => { // A Finder / Dock launch on macOS inherits launchd's minimal PATH and never // reads the user's profile, so `process.env.PATH` says ~/.local/bin is // missing while the user's terminal has had it all along. Reading only that @@ -250,3 +240,260 @@ describe('PATH detection follows the user\'s shell, not the app\'s (#528)', () = } ) }) + +describe('existing Node CLI migration', () => { + it.skipIf(process.platform === 'win32')( + 'repoints the existing owned zn without creating a new PATH entry', + async () => { + const { migrateInstalledCli } = await import('./cli-install') + const bin = path.join(home, '.local', 'bin') + await mkdir(bin, { recursive: true }) + const old = wrapperLoc().wrapperPath + const replacement = path.join(userDataDir, 'cli', 'zn') + await symlink(old, path.join(bin, 'zn')) + const migrated = await migrateInstalledCli({ + ...wrapperLoc(), + wrapperPath: replacement, + legacyWrapperPaths: [old], + runtime: 'go', + version: '1.0.0' + }) + expect(migrated).toBe(path.join(bin, 'zn')) + expect(await readlink(path.join(bin, 'zn'))).toBe(replacement) + expect(await linkExists(path.join(bin, 'zen'))).toBe(false) + } + ) + + it.skipIf(process.platform === 'win32')( + 'leaves a foreign command and an unrequested installation alone', + async () => { + const { migrateInstalledCli } = await import('./cli-install') + const bin = path.join(home, '.local', 'bin') + await mkdir(bin, { recursive: true }) + const wrapper = { + ...wrapperLoc(), + runtime: 'go' as const, + version: '1.0.0' + } + expect(await migrateInstalledCli(wrapper)).toBeNull() + const target = path.join(bin, 'zn') + await writeFile(target, '#!/bin/sh\necho external\n') + expect(await migrateInstalledCli(wrapper)).toBeNull() + expect(await fsPromises.readFile(target, 'utf8')).toContain('external') + } + ) +}) + +it.skipIf(process.platform === 'win32')( + 'does not let a foreign executable outside PATH block a new installation', + async () => { + const offPath = path.join(home, 'bin') + await mkdir(offPath, { recursive: true }) + await writeFile(path.join(offPath, 'zn'), '#!/bin/sh\necho foreign\n') + const status = await getCliInstallStatus() + expect(status.installedAt).toBeNull() + } +) + +it.skipIf(process.platform === 'win32')( + 'recognizes an owned wrapper through a symlinked app directory', + async () => { + const { migrateInstalledCli } = await import('./cli-install') + const bin = path.join(home, '.local', 'bin') + await mkdir(bin, { recursive: true }) + const alias = path.join(home, 'App alias') + await symlink(userDataDir, alias) + await symlink(path.join(alias, 'zen'), path.join(bin, 'zn')) + const replacement = path.join(userDataDir, 'cli', 'zn') + const result = await migrateInstalledCli({ + ...wrapperLoc(), + wrapperPath: replacement, + legacyWrapperPaths: [wrapperLoc().wrapperPath], + runtime: 'go' + }) + expect(result).toBe(path.join(bin, 'zn')) + expect(await readlink(path.join(bin, 'zn'))).toBe(replacement) + } +) + +// The desktop CLI shortcut is a POSIX symlink and the repair paths are +// AppImage mounts and macOS bundles; CLI install is not offered on Windows. +describe.skipIf(process.platform === 'win32')('stale desktop CLI shortcuts', () => { + const goWrapper = () => ({ + ...wrapperLoc(), + wrapperPath: path.join(userDataDir, 'cli', 'zn'), + runtime: 'go' as const + }) + + it('offers explicit repair for a missing AppImage mount without migrating it on startup', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + const { migrateInstalledCli } = await import('./cli-install') + const bin = path.join(home, '.local', 'bin') + await mkdir(bin, { recursive: true }) + const link = path.join(bin, 'zn') + const old = '/tmp/.mount_ZenNotABC123/resources/zen' + await symlink(old, link) + + expect(await migrateInstalledCli(goWrapper())).toBeNull() + expect(await readlink(link)).toBe(old) + const status = await getCliInstallStatus(goWrapper()) + expect(status.installedByThisApp).toBe(false) + expect(status.repair).toMatchObject({ + oldTarget: old, + newTarget: goWrapper().wrapperPath + }) + expect(status.repair?.token).toBeTruthy() + }) + + it('repairs a reviewed missing macOS app link in place and saves its previous target', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const { installCli } = await import('./cli-install') + const bin = path.join(home, '.local', 'bin') + await mkdir(bin, { recursive: true }) + const link = path.join(bin, 'zn') + const old = path.join(home, 'Moved', 'ZenNotes.app', 'Contents', 'Resources', 'zen') + await symlink(old, link) + const status = await getCliInstallStatus(goWrapper()) + + expect(status.repair).toBeDefined() + await expect(installCli(undefined, goWrapper())).rejects.toThrow(/not managed/) + await installCli({ repairToken: status.repair!.token }, goWrapper()) + expect(await readlink(link)).toBe(goWrapper().wrapperPath) + const backup = JSON.parse(await fsPromises.readFile(status.repair!.backupPath, 'utf8')) + expect(backup).toMatchObject({ linkPath: link, linkTarget: old }) + const repaired = await getCliInstallStatus(goWrapper()) + expect(repaired.installedByThisApp).toBe(true) + // Settings shows "Repair shortcut" for an offer and "Repair" for an owned + // runtime failure; an owned shortcut must never carry an offer as well. + expect(repaired.repair).toBeUndefined() + }) + + it('never offers repair for a live matching-looking app link or an unrelated dangling link', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const bin = path.join(home, '.local', 'bin') + await mkdir(bin, { recursive: true }) + const link = path.join(bin, 'zn') + const foreign = path.join(home, 'Another', 'ZenNotes.app', 'Contents', 'Resources', 'zen') + await mkdir(path.dirname(foreign), { recursive: true }) + await writeFile(foreign, '#!/bin/sh\necho foreign\n') + await symlink(foreign, link) + expect((await getCliInstallStatus(goWrapper())).repair).toBeUndefined() + await rm(link) + await symlink(path.join(home, 'some-zennotes-other', 'zen'), link) + expect((await getCliInstallStatus(goWrapper())).repair).toBeUndefined() + }) + + it('refuses a reviewed repair if another installer changed the shortcut', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + const { installCli } = await import('./cli-install') + const bin = path.join(home, '.local', 'bin') + await mkdir(bin, { recursive: true }) + const link = path.join(bin, 'zn') + await symlink('/tmp/.mount_ZenNotABC123/resources/zen', link) + const status = await getCliInstallStatus(goWrapper()) + expect(status.repair).toBeDefined() + const foreign = path.join(home, 'foreign') + await rm(link) + await symlink(foreign, link) + + await expect(installCli({ repairToken: status.repair!.token }, goWrapper())).rejects.toThrow( + /changed|refresh/i + ) + expect(await readlink(link)).toBe(foreign) + }) + + it('refuses repair after the former app location becomes live again', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const { installCli } = await import('./cli-install') + const bin = path.join(home, '.local', 'bin') + await mkdir(bin, { recursive: true }) + const link = path.join(bin, 'zn') + const old = path.join(home, 'Moved', 'ZenNotes.app', 'Contents', 'Resources', 'zen') + await symlink(old, link) + const status = await getCliInstallStatus(goWrapper()) + expect(status.repair).toBeDefined() + await mkdir(path.dirname(old), { recursive: true }) + await writeFile(old, '#!/bin/sh\necho installed-again\n') + + await expect(installCli({ repairToken: status.repair!.token }, goWrapper())).rejects.toThrow( + /changed|refresh/i + ) + expect(await readlink(link)).toBe(old) + }) + + it('rechecks that the previous app target is missing immediately before replacing the link', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const { installCli } = await import('./cli-install') + const bin = path.join(home, '.local', 'bin') + await mkdir(bin, { recursive: true }) + const link = path.join(bin, 'zn') + const old = path.join(home, 'Moved', 'ZenNotes.app', 'Contents', 'Resources', 'zen') + await symlink(old, link) + const status = await getCliInstallStatus(goWrapper()) + const realWriteFile = fsPromises.writeFile + vi.spyOn(fsPromises, 'writeFile').mockImplementation(async (...args) => { + await realWriteFile(...args) + if (String(args[0]) === status.repair!.backupPath) { + await mkdir(path.dirname(old), { recursive: true }) + await realWriteFile(old, '#!/bin/sh\necho returned-during-repair\n') + } + }) + + await expect(installCli({ repairToken: status.repair!.token }, goWrapper())).rejects.toThrow( + /changed|missing/i + ) + expect(await readlink(link)).toBe(old) + }) + + it('rejects arbitrary renderer replacement paths and invented repair tokens', async () => { + const { installCli } = await import('./cli-install') + await expect( + installCli( + { + repairToken: '00000000-0000-0000-0000-000000000000', + target: '/tmp/foreign' + }, + goWrapper() + ) + ).rejects.toThrow(/invalid/i) + await expect( + installCli({ repairToken: '00000000-0000-0000-0000-000000000000' }, goWrapper()) + ).rejects.toThrow(/expired|refresh/i) + }) + + it('removes a recorded historical shortcut on uninstall and retires its ownership', async () => { + const { installCli, uninstallCli, migrateInstalledCli } = await import('./cli-install') + const bin = path.join(home, '.local', 'bin') + await mkdir(bin, { recursive: true }) + const old = { ...wrapperLoc(), runtime: 'node' as const } + await installCli(undefined, old) + const link = path.join(bin, 'zn') + await rm(old.wrapperPath) + + await uninstallCli(goWrapper()) + expect(await linkExists(link)).toBe(false) + await symlink(old.wrapperPath, link) + expect(await migrateInstalledCli(goWrapper())).toBeNull() + expect(await readlink(link)).toBe(old.wrapperPath) + }) + + it('records exact ownership on installation and uses it after the app moves', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + const { installCli, migrateInstalledCli } = await import('./cli-install') + const bin = path.join(home, '.local', 'bin') + await mkdir(bin, { recursive: true }) + const old = { ...wrapperLoc(), runtime: 'node' as const } + await installCli(undefined, old) + const link = path.join(bin, 'zn') + expect(await readlink(link)).toBe(old.wrapperPath) + await rm(old.wrapperPath) + const replacement = goWrapper() + expect(await migrateInstalledCli(replacement)).toBe(link) + expect(await readlink(link)).toBe(replacement.wrapperPath) + + await rm(link) + await symlink(path.join(home, 'foreign'), link) + expect(await migrateInstalledCli(replacement)).toBeNull() + expect(await readlink(link)).toBe(path.join(home, 'foreign')) + }) +}) diff --git a/apps/desktop/src/main/cli-install.ts b/apps/desktop/src/main/cli-install.ts index 0dd98c7c..b28922d7 100644 --- a/apps/desktop/src/main/cli-install.ts +++ b/apps/desktop/src/main/cli-install.ts @@ -4,8 +4,8 @@ * * The wrapper script `build/zen` ships in the packaged app at * Contents/Resources/zen (macOS) or resources/zen (Linux). Installing - * the CLI means creating a symlink to that wrapper somewhere on the - * user's $PATH. + * the CLI uses a verified persistent Go runtime when the app includes one, + * retaining that resource path and the Node CLI for transition compatibility. * * We deliberately avoid a sudo / admin prompt by default. Most macOS * and Linux setups already have at least one user-writable directory @@ -22,6 +22,8 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import os from 'node:os' +import { randomUUID } from 'node:crypto' +import { prepareTerminalRuntime, readActiveTerminalRuntime } from './terminal-runtime' import type { CliInstallStatus } from '@shared/ipc' import { resolveLoginShellPathDirs } from './login-shell-path' @@ -42,6 +44,205 @@ const LEGACY_CLI_NAMES = ['zen'] interface WrapperLocation { wrapperPath: string cliJsPath: string + legacyWrapperPaths?: string[] + runtime?: 'go' | 'node' + version?: string + runtimeError?: string +} + +let migrationError: string | undefined + +interface InstallReceipt { + linkPath: string + linkTarget: string +} + +interface RepairOffer { + token: string + linkPath: string + oldTarget: string + newTarget: string + backupPath: string + expiresAt: number +} + +let repairOffer: RepairOffer | undefined + +function receiptPath(): string { + return path.join(app.getPath('userData'), 'cli', 'install-receipts.json') +} + +async function readInstallReceipts(): Promise { + try { + const data = JSON.parse(await fsp.readFile(receiptPath(), 'utf8')) + if (data?.schemaVersion !== 1 || !Array.isArray(data.links) || data.links.length > 128) + return [] + return data.links.filter((item: unknown): item is InstallReceipt => { + if (!item || typeof item !== 'object') return false + const value = item as InstallReceipt + return ( + typeof value.linkPath === 'string' && + path.isAbsolute(value.linkPath) && + [CLI_NAME, ...LEGACY_CLI_NAMES].includes(path.basename(value.linkPath)) && + typeof value.linkTarget === 'string' && + value.linkTarget.length > 0 && + !value.linkTarget.includes('\0') + ) + }) + } catch { + return [] + } +} + +async function writeInstallReceipts(entries: InstallReceipt[]): Promise { + const target = receiptPath() + await fsp.mkdir(path.dirname(target), { recursive: true }) + const temporary = `${target}.${randomUUID()}.tmp` + try { + await fsp.writeFile( + temporary, + JSON.stringify({ schemaVersion: 1, links: entries.slice(-128) }), + { mode: 0o600 } + ) + await fsp.rename(temporary, target) + } finally { + await fsp.rm(temporary, { force: true }) + } +} + +async function recordInstall(linkPath: string, linkTarget: string): Promise { + if ((await fsp.readlink(linkPath)) !== linkTarget) + throw new Error(`${linkPath} changed during installation.`) + const entries = (await readInstallReceipts()).filter((entry) => entry.linkPath !== linkPath) + entries.push({ linkPath, linkTarget }) + await writeInstallReceipts(entries) +} + +async function forgetInstall(linkPath: string, linkTarget: string): Promise { + const entries = await readInstallReceipts() + const retained = entries.filter( + (entry) => entry.linkPath !== linkPath || entry.linkTarget !== linkTarget + ) + if (retained.length !== entries.length) await writeInstallReceipts(retained) +} + +async function ownedInstall( + linkPath: string, + linkTarget: string, + wrapper: WrapperLocation | null, + receipts: InstallReceipt[] +): Promise { + const resolved = path.resolve(path.dirname(linkPath), linkTarget) + return ( + (wrapper ? matchesWrapper(resolved, wrapper) : looksLikeOurInstall(resolved)) || + receipts.some((entry) => entry.linkPath === linkPath && entry.linkTarget === linkTarget) + ) +} + +async function isMissingHistoricalTarget(linkPath: string, linkTarget: string): Promise { + const resolved = path.resolve(path.dirname(linkPath), linkTarget) + // A recognizable name only permits a reviewed repair offer, never ownership. + const historical = + process.platform === 'linux' + ? /^\/(?:tmp|var\/tmp|run\/user\/\d+)\/\.mount_ZenNot[A-Za-z0-9]+\/resources\/zen$/i.test( + resolved + ) + : process.platform === 'darwin' && /\/ZenNotes\.app\/Contents\/Resources\/zen$/.test(resolved) + if (!historical) return false + try { + await fsp.stat(linkPath) + return false + } catch (error) { + return ['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '') + } +} + +async function offerRepair( + existing: ExistingInstall | null, + wrapper: WrapperLocation | null +): Promise { + if ( + !wrapper || + wrapper.runtime !== 'go' || + !existing?.linkTarget || + existing.installedByThisApp || + path.basename(existing.linkPath) !== CLI_NAME || + !(await isMissingHistoricalTarget(existing.linkPath, existing.linkTarget)) + ) + return undefined + if ( + !repairOffer || + repairOffer.expiresAt < Date.now() || + repairOffer.linkPath !== existing.linkPath || + repairOffer.oldTarget !== existing.linkTarget || + repairOffer.newTarget !== wrapper.wrapperPath + ) { + const token = randomUUID() + repairOffer = { + token, + linkPath: existing.linkPath, + oldTarget: existing.linkTarget, + newTarget: wrapper.wrapperPath, + expiresAt: Date.now() + 5 * 60_000, + backupPath: path.join(app.getPath('userData'), 'cli', 'link-backups', `${token}.json`) + } + } + const { token, oldTarget, newTarget, backupPath } = repairOffer + return { token, oldTarget, newTarget, backupPath } +} + +function readRepairToken(request: unknown): string | undefined { + if (request === undefined) return undefined + if (!request || typeof request !== 'object' || Array.isArray(request)) + throw new Error('Invalid CLI install request.') + const input = request as Record + if ( + Object.keys(input).length !== 1 || + typeof input.repairToken !== 'string' || + !/^[0-9a-f-]{36}$/.test(input.repairToken) + ) + throw new Error('Invalid CLI repair request.') + return input.repairToken +} + +async function reviewRepair( + token: string, + existing: ExistingInstall | null, + wrapper: WrapperLocation +): Promise { + const offer = repairOffer + if ( + !offer || + offer.token !== token || + offer.expiresAt < Date.now() || + !existing || + existing.linkPath !== offer.linkPath || + existing.linkTarget !== offer.oldTarget || + wrapper.wrapperPath !== offer.newTarget || + wrapper.runtime !== 'go' || + !(await isMissingHistoricalTarget(existing.linkPath, existing.linkTarget)) + ) { + throw new Error( + 'The CLI shortcut changed or the repair offer expired. Refresh Settings and review it again.' + ) + } + repairOffer = undefined + await fsp.mkdir(path.dirname(offer.backupPath), { recursive: true }) + await fsp.writeFile( + offer.backupPath, + JSON.stringify( + { + schemaVersion: 1, + linkPath: offer.linkPath, + linkTarget: offer.oldTarget, + savedAt: new Date().toISOString() + }, + null, + 2 + ), + { mode: 0o600, flag: 'wx' } + ) + return offer } async function locateWrapper(): Promise { @@ -67,7 +268,37 @@ async function locateWrapper(): Promise { fsp.stat(c.wrapperPath), fsp.stat(c.cliJsPath) ]) - if (wrapperStat.isFile() && cliStat.isFile()) return c + if (wrapperStat.isFile() && cliStat.isFile()) { + if (process.platform !== 'darwin' && process.platform !== 'linux') return c + const legacyWrapperPaths = candidates.map((candidate) => candidate.wrapperPath) + let runtimeError: string | undefined + let terminal + try { + terminal = await prepareTerminalRuntime({ + bundleDir: app.isPackaged + ? path.join(process.resourcesPath, 'terminal') + : path.resolve(here, '../../build/terminal', `${process.platform}-${process.arch}`), + userData: app.getPath('userData'), + platform: process.platform, + arch: process.arch, + legacyCommand: [process.execPath, c.cliJsPath], + appPath: process.env.APPIMAGE || (app.isPackaged ? process.execPath : undefined) + }) + } catch (error) { + runtimeError = (error as Error).message + } + terminal ??= await readActiveTerminalRuntime(app.getPath('userData')) + if (terminal) + return { + ...c, + wrapperPath: terminal.launcherPath, + legacyWrapperPaths, + runtime: 'go', + version: terminal.version, + runtimeError + } + return { ...c, legacyWrapperPaths, runtime: 'node', runtimeError } + } } catch { /* keep trying */ } @@ -83,7 +314,8 @@ async function ensureDevWrapper(cliJsPath: string): Promise { const script = [ '#!/bin/sh', '# Auto-generated dev wrapper for the ZenNotes CLI.', - `ELECTRON_RUN_AS_NODE=1 exec "${electronBinary}" "${cliJsPath}" "$@"`, + `if [ "${'${ZENNOTES_CLI_ENGINE:-go}'}" != legacy ] && [ -x ${shellQuote(path.join(dir, 'zn'))} ]; then exec ${shellQuote(path.join(dir, 'zn'))} "$@"; fi`, + `ELECTRON_RUN_AS_NODE=1 exec ${shellQuote(electronBinary)} ${shellQuote(cliJsPath)} "$@"`, '' ].join('\n') await fsp.writeFile(target, script, { mode: 0o755 }) @@ -229,12 +461,16 @@ function pathExportSnippet(dir: string): string { /* ---------- Existing-install discovery --------------------------------- */ function looksLikeOurInstall(linkTarget: string): boolean { - const userDataCli = path.join(app.getPath('userData'), 'cli') - return ( - linkTarget.startsWith(userDataCli) || - (process.resourcesPath && linkTarget.startsWith(process.resourcesPath)) || - linkTarget.includes('/ZenNotes.app/') || - linkTarget.includes('/zennotes/apps/desktop/') + return [ + path.join(app.getPath('userData'), 'cli', 'zen'), + path.join(app.getPath('userData'), 'cli', 'zn'), + ...(process.resourcesPath ? [path.join(process.resourcesPath, WRAPPER_NAME)] : []) + ].some((candidate) => sameFile(candidate, linkTarget)) +} + +function matchesWrapper(target: string, wrapper: WrapperLocation): boolean { + return [wrapper.wrapperPath, ...(wrapper.legacyWrapperPaths ?? [])].some((candidate) => + sameFile(target, candidate) ) } @@ -242,28 +478,38 @@ interface ExistingInstall { linkPath: string /** True when the symlink resolves to our wrapper for this build. */ installedByThisApp: boolean + linkTarget?: string } async function findInstallByName( name: string, wrapper: WrapperLocation | null ): Promise { - for (const dir of await candidateDirs()) { + const onPath = await pathDirsOnPath() + const receipts = await readInstallReceipts() + const dirs = [ + ...new Set( + [ + ...onPath, + ...(await candidateDirs()), + ...receipts.map((entry) => path.dirname(entry.linkPath)) + ].map((dir) => path.resolve(dir)) + ) + ] + for (const dir of dirs) { const candidate = path.join(dir, name) try { const linkTarget = await fsp.readlink(candidate) - const resolved = path.isAbsolute(linkTarget) - ? linkTarget - : path.resolve(dir, linkTarget) - const byUs = wrapper ? sameFile(resolved, wrapper.wrapperPath) : looksLikeOurInstall(resolved) - return { linkPath: candidate, installedByThisApp: byUs } + const byUs = await ownedInstall(candidate, linkTarget, wrapper, receipts) + if (byUs || onPath.has(dir)) + return { linkPath: candidate, installedByThisApp: byUs, linkTarget } } catch (err) { if ((err as NodeJS.ErrnoException).code === 'EINVAL') { // Real file, not a symlink. Treat as a foreign install we // refuse to manage. try { await fsp.access(candidate) - return { linkPath: candidate, installedByThisApp: false } + if (onPath.has(dir)) return { linkPath: candidate, installedByThisApp: false } } catch { /* fall through */ } @@ -324,6 +570,7 @@ export async function migrateLegacyCliLink( const linkPath = path.join(path.dirname(found.linkPath), CLI_NAME) try { await writeSymlink(wrapper.wrapperPath, linkPath) + await recordInstall(linkPath, wrapper.wrapperPath) } catch { // A read-only bin dir at launch is not worth a dialog; the explicit // Install path still exists and can elevate. @@ -335,9 +582,36 @@ export async function migrateLegacyCliLink( return null } +/** Update only an existing managed command. Startup never installs a new one. */ +export async function migrateInstalledCli( + wrapperOverride?: WrapperLocation | null +): Promise { + if (process.platform !== 'darwin' && process.platform !== 'linux') return null + const wrapper = wrapperOverride === undefined ? await locateWrapper() : wrapperOverride + if (!wrapper || wrapper.runtime !== 'go') return null + const existing = await findInstallByName(CLI_NAME, wrapper) + if (!existing?.installedByThisApp) return null + try { + if (existing.linkTarget === wrapper.wrapperPath) { + await recordInstall(existing.linkPath, wrapper.wrapperPath) + return null + } + await writeSymlink(wrapper.wrapperPath, existing.linkPath, existing.linkTarget) + await recordInstall(existing.linkPath, wrapper.wrapperPath) + migrationError = undefined + return existing.linkPath + } catch (error) { + migrationError = `The terminal upgrade needs repair: ${(error as Error).message}` + return null + } +} + function sameFile(a: string, b: string): boolean { + if (path.resolve(a) === path.resolve(b)) return true try { - return path.resolve(a) === path.resolve(b) + // macOS aliases /tmp to /private/tmp; app folders can also be symlinked. + // Compare existing canonical paths without broadening stale-link ownership. + return fs.realpathSync(a) === fs.realpathSync(b) } catch { return false } @@ -345,7 +619,9 @@ function sameFile(a: string, b: string): boolean { /* ---------- Status read ----------------------------------------------- */ -export async function getCliInstallStatus(): Promise { +export async function getCliInstallStatus( + wrapperOverride?: WrapperLocation | null +): Promise { const supportedPlatform = process.platform === 'darwin' || process.platform === 'linux' if (!supportedPlatform) { return { @@ -361,12 +637,16 @@ export async function getCliInstallStatus(): Promise { } } - const wrapper = await locateWrapper() + const wrapper = wrapperOverride === undefined ? await locateWrapper() : wrapperOverride const target = await pickInstallTarget() const existing = await findExistingInstall(wrapper) return { + repair: await offerRepair(existing, wrapper), available: wrapper != null, + runtime: wrapper?.runtime, + runtimeVersion: wrapper?.version, + runtimeError: wrapper?.runtimeError ?? migrationError, reason: wrapper ? null : 'The CLI has not been built yet. Run `npm run build` (or use a packaged build) so Settings has a wrapper to install.', @@ -391,23 +671,24 @@ export async function removeManagedLinks( wrapper: WrapperLocation | null ): Promise { const removed: string[] = [] - for (const dir of await candidateDirs()) { + const receipts = await readInstallReceipts() + const dirs = new Set([ + ...(await candidateDirs()), + ...receipts.map((entry) => path.dirname(entry.linkPath)) + ]) + for (const dir of dirs) { for (const name of names) { const candidate = path.join(dir, name) try { const linkTarget = await fsp.readlink(candidate) - const resolved = path.isAbsolute(linkTarget) - ? linkTarget - : path.resolve(dir, linkTarget) - const byUs = wrapper - ? sameFile(resolved, wrapper.wrapperPath) - : looksLikeOurInstall(resolved) - if (byUs) { - await fsp.rm(candidate, { force: true }) + if (await ownedInstall(candidate, linkTarget, wrapper, receipts)) { + if ((await fsp.readlink(candidate)) !== linkTarget) continue + await fsp.unlink(candidate) + await forgetInstall(candidate, linkTarget) removed.push(candidate) } } catch { - /* not a symlink / missing / unreadable — leave it alone */ + /* not a symlink / missing / unreadable, leave it alone */ } } } @@ -416,11 +697,15 @@ export async function removeManagedLinks( /* ---------- Install --------------------------------------------------- */ -export async function installCli(): Promise { +export async function installCli( + request?: unknown, + wrapperOverride?: WrapperLocation | null +): Promise { + const repairToken = readRepairToken(request) if (process.platform === 'win32') { throw new Error('CLI install is not yet supported on Windows.') } - const wrapper = await locateWrapper() + const wrapper = wrapperOverride === undefined ? await locateWrapper() : wrapperOverride if (!wrapper) { throw new Error( 'The CLI wrapper is not bundled with this build. Run `npm run build` (or launch from a packaged build) and try again.' @@ -430,10 +715,11 @@ export async function installCli(): Promise { // If something is already installed at one of our candidates, prefer // overwriting it in place rather than creating a second copy on PATH. const existing = await findExistingInstall(wrapper) + const repair = repairToken ? await reviewRepair(repairToken, existing, wrapper) : undefined let target: InstallTarget - if (existing && existing.installedByThisApp) { + if (existing && (existing.installedByThisApp || repair)) { target = { - linkPath: existing.linkPath, + linkPath: path.join(path.dirname(existing.linkPath), CLI_NAME), onPath: (await pathDirsOnPath()).has(path.dirname(existing.linkPath)), requiresSudo: !(await isWritableDir(path.dirname(existing.linkPath))), pathHint: null @@ -450,44 +736,94 @@ export async function installCli(): Promise { await fsp.mkdir(linkDir, { recursive: true }).catch(() => undefined) if (!target.requiresSudo) { - await writeSymlink(wrapper.wrapperPath, target.linkPath) + await writeSymlink( + wrapper.wrapperPath, + target.linkPath, + target.linkPath === existing?.linkPath ? existing.linkTarget : undefined, + Boolean(repair) + ) } else { try { - await writeSymlink(wrapper.wrapperPath, target.linkPath) + await writeSymlink( + wrapper.wrapperPath, + target.linkPath, + target.linkPath === existing?.linkPath ? existing.linkTarget : undefined, + Boolean(repair) + ) } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') { - await elevateAndSymlink(wrapper.wrapperPath, target.linkPath) + await elevateAndSymlink( + wrapper.wrapperPath, + target.linkPath, + target.linkPath === existing?.linkPath ? existing.linkTarget : undefined, + Boolean(repair) + ) } else { throw err } } } + await recordInstall(target.linkPath, wrapper.wrapperPath) + // #126: migrate off the legacy `zen` name — drop any ZenNotes-managed `zen` // symlink now that `zn` is installed. await removeManagedLinks(LEGACY_CLI_NAMES, wrapper) - return await getCliInstallStatus() + migrationError = undefined + return await getCliInstallStatus(wrapperOverride) } -async function writeSymlink(source: string, target: string): Promise { +async function writeSymlink( + source: string, + target: string, + expectedTarget?: string, + requireMissing = false +): Promise { try { await fsp.symlink(source, target) - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'EEXIST') { - await fsp.rm(target, { force: true }) - await fsp.symlink(source, target) - return + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } + const current = await fsp.readlink(target).catch(() => null) + if (current === source) return + if (expectedTarget === undefined || current !== expectedTarget) { + throw new Error(`${target} changed or is not a managed symlink. It was left untouched.`) + } + const temporary = `${target}.${randomUUID()}.tmp` + try { + await fsp.symlink(source, temporary) + if (requireMissing && !(await isMissingHistoricalTarget(target, expectedTarget))) { + throw new Error(`${target} changed: the previous app target is no longer missing.`) } - throw err + if ((await fsp.readlink(target)) !== expectedTarget) + throw new Error(`${target} changed during installation.`) + await fsp.rename(temporary, target) + } finally { + await fsp.rm(temporary, { force: true }) } } -async function elevateAndSymlink(source: string, target: string): Promise { +async function elevateAndSymlink( + source: string, + target: string, + expectedTarget?: string, + requireMissing = false +): Promise { + const ownershipGuard = + expectedTarget === undefined + ? `[ ! -e ${shellQuote(target)} ] && [ ! -L ${shellQuote(target)} ]` + : `[ -L ${shellQuote(target)} ] && [ "$(readlink ${shellQuote(target)})" = ${shellQuote(expectedTarget)} ]` + + const guard = requireMissing + ? `${ownershipGuard} && [ ! -e ${shellQuote(target)} ]` + : ownershipGuard + if (process.platform === 'darwin') { const shellCmd = - `mkdir -p ${shellQuote(path.dirname(target))} && ` + + `mkdir -p ${shellQuote(path.dirname(target))} && ${guard} && ` + `ln -sf ${shellQuote(source)} ${shellQuote(target)}` const appleScript = `do shell script "${appleScriptEscape(shellCmd)}" with administrator privileges` try { @@ -508,10 +844,13 @@ async function elevateAndSymlink(source: string, target: string): Promise await execFileAsync('pkexec', [ 'sh', '-c', - `mkdir -p ${shellQuote(path.dirname(target))} && ln -sf ${shellQuote(source)} ${shellQuote(target)}` + `mkdir -p ${shellQuote(path.dirname(target))} && ${guard} && ln -sf ${shellQuote(source)} ${shellQuote(target)}` ]) return } catch { + if (requireMissing) { + throw new Error(`Could not repair ${target}. Administrator access was declined or the shortcut changed. Refresh Settings and review it again.`) + } throw new Error( `${target} is not writable and pkexec is unavailable. Run this manually:\n sudo ln -sf "${source}" "${target}"` ) @@ -530,14 +869,16 @@ function appleScriptEscape(value: string): string { /* ---------- Uninstall ------------------------------------------------- */ -export async function uninstallCli(): Promise { +export async function uninstallCli( + wrapperOverride?: WrapperLocation | null +): Promise { if (process.platform === 'win32') { throw new Error('CLI install is not yet supported on Windows.') } - const wrapper = await locateWrapper() + const wrapper = wrapperOverride === undefined ? await locateWrapper() : wrapperOverride const existing = await findExistingInstall(wrapper) if (!existing) { - return await getCliInstallStatus() + return await getCliInstallStatus(wrapperOverride) } if (!existing.installedByThisApp) { throw new Error( @@ -545,13 +886,21 @@ export async function uninstallCli(): Promise { ) } + const expectedTarget = existing.linkTarget + if ( + !expectedTarget || + (await fsp.readlink(existing.linkPath).catch(() => null)) !== expectedTarget + ) { + throw new Error(`${existing.linkPath} changed and was left untouched.`) + } + const removalGuard = `[ -L ${shellQuote(existing.linkPath)} ] && [ "$(readlink ${shellQuote(existing.linkPath)})" = ${shellQuote(expectedTarget)} ]` try { await fsp.unlink(existing.linkPath) } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code === 'EACCES' || code === 'EPERM') { if (process.platform === 'darwin') { - const shellCmd = `rm -f ${shellQuote(existing.linkPath)}` + const shellCmd = `${removalGuard} && rm -f ${shellQuote(existing.linkPath)}` const appleScript = `do shell script "${appleScriptEscape(shellCmd)}" with administrator privileges` await execFileAsync('osascript', ['-e', appleScript]).catch((e) => { const stderr = (e as { stderr?: string }).stderr ?? '' @@ -562,7 +911,11 @@ export async function uninstallCli(): Promise { ) }) } else { - await execFileAsync('pkexec', ['rm', '-f', existing.linkPath]).catch((e) => { + await execFileAsync('pkexec', [ + 'sh', + '-c', + `${removalGuard} && rm -f ${shellQuote(existing.linkPath)}` + ]).catch((e) => { throw new Error( `Could not remove ${existing.linkPath}. Run this manually:\n sudo rm "${existing.linkPath}"\n(${(e as Error).message})` ) @@ -572,10 +925,11 @@ export async function uninstallCli(): Promise { throw err } } + await forgetInstall(existing.linkPath, expectedTarget) // #126: sweep any strays too — a legacy `zen` in another dir, or a second `zn` // — so uninstall fully removes ZenNotes-managed links. await removeManagedLinks([CLI_NAME, ...LEGACY_CLI_NAMES], wrapper) - return await getCliInstallStatus() + return await getCliInstallStatus(wrapperOverride) } /* ---------- Used by mcp-integrations.ts to prefer `zn mcp` ------------ */ diff --git a/apps/desktop/src/main/cloud-sync-filesystem.ts b/apps/desktop/src/main/cloud-sync-filesystem.ts index 98e398d0..fd7550f3 100644 --- a/apps/desktop/src/main/cloud-sync-filesystem.ts +++ b/apps/desktop/src/main/cloud-sync-filesystem.ts @@ -454,6 +454,16 @@ export class DesktopCloudSyncStateStore implements CloudSyncStateStore { } } + async retire(vaultId: string, archiveDirectory: string): Promise { + await fs.mkdir(archiveDirectory, { recursive: true }) + const archived = path.join(archiveDirectory, `${sha256(Buffer.from(vaultId))}.${randomUUID()}.json`) + try { + await fs.rename(this.statePath(vaultId), archived) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + private statePath(vaultId: string): string { return path.join(this.directory, `${sha256(Buffer.from(vaultId))}.json`) } diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts index cb2d64cf..0aee4f6b 100644 --- a/apps/desktop/src/main/cloud-sync-service.test.ts +++ b/apps/desktop/src/main/cloud-sync-service.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { createHash } from 'node:crypto' import os from 'node:os' import path from 'node:path' @@ -193,7 +193,7 @@ async function setup( withWindowSync, now: () => new Date('2026-08-10T12:00:00.000Z') }) - return { service, client, localRoot } + return { service, client, localRoot, storageDirectory } } describe('DesktopCloudSyncService', () => { @@ -704,3 +704,175 @@ describe('DesktopCloudSyncService', () => { expect(client.createBackup).not.toHaveBeenCalled() }) }) + + +describe('deleted Cloud vault recovery (#791)', () => { + const cloudVault = (id = 'vault-1'): CloudSyncVault => ({ + id, name: id, cursor: 0, + created_at: '2026-09-16T12:00:00.000Z', updated_at: '2026-09-16T12:00:00.000Z' + }) + const missing = () => new CloudServiceRequestError( + 'The requested resource was not found.', 404, 'NOT_FOUND' + ) + const fingerprint = (value: string) => createHash('sha256').update(value).digest('hex') + const savedStatePath = (storage: string, root: string, id = 'vault-1') => path.join( + storage, 'states', fingerprint(path.resolve(root)), fingerprint('https://zennotes.org'), + `${fingerprint(id)}.json` + ) + + it('unlinks only the confirmed missing association and removes its sync state without touching local notes', async () => { + const { service, client, localRoot, storageDirectory } = await setup([cloudVault(), cloudVault('vault-2')]) + await service.link(localRoot, 'vault-1') + await service.sync(localRoot) + const otherRoot = await mkdtemp(path.join(os.tmpdir(), 'zennotes-other-vault-')) + temporaryDirectories.push(otherRoot) + const otherLink = await service.link(otherRoot, 'vault-2') + await service.sync(otherRoot) + const otherState = await readFile(savedStatePath(storageDirectory, otherRoot, 'vault-2')) + const note = Buffer.from('---\ntitle: Keep me\n---\r\nUnsent local edit 📝\r\n') + await writeFile(path.join(localRoot, 'note.md'), note) + client.changes.mockRejectedValue(missing()) + client.manifest.mockRejectedValue(missing()) + + await expect(service.sync(localRoot)).rejects.toThrow() + + expect(await service.linkedVault(localRoot)).toBeNull() + await expect(readFile(savedStatePath(storageDirectory, localRoot))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(path.join(localRoot, 'note.md'))).toEqual(note) + expect(await service.linkedVault(otherRoot)).toEqual(otherLink) + expect(await readFile(savedStatePath(storageDirectory, otherRoot, 'vault-2'))).toEqual(otherState) + expect((await service.serviceAccount()).user.email).toBe('ada@example.com') + expect(client.deleteVault).not.toHaveBeenCalled() + }) + + it('archives every byte of unresolved conflict drafts outside active sync state without overwriting earlier recoveries', async () => { + const { service, client, localRoot, storageDirectory } = await setup([cloudVault()]) + const snapshots: Buffer[] = [] + const draft = 'Unsent merge draft\r\nKeep every byte 📝\r\n' + await writeFile(path.join(localRoot, 'Note.md'), 'local version') + for (let attempt = 1; attempt <= 2; attempt++) { + const cloudText = 'cloud version' + const content = { + encoding: 'utf8' as const, data: cloudText, + sha256: createHash('sha256').update(cloudText).digest('hex'), + byte_length: Buffer.byteLength(cloudText), media_type: 'text/markdown' + } + client.changes.mockResolvedValue({ data: [], cursor: 1, has_more: false }) + client.manifest.mockResolvedValue({ + data: [{ item_id: 'item-1', path: 'Note.md', kind: 'text', revision: 2, + sha256: content.sha256, byte_length: content.byte_length, media_type: content.media_type, content }], + cursor: 1, next_page: null + }) + await service.link(localRoot, 'vault-1') + const conflict = (await service.sync(localRoot)).pending_conflicts![0]! + await service.saveConflictDraft(localRoot, conflict.id, `${draft}${attempt}`) + snapshots.push(await readFile(savedStatePath(storageDirectory, localRoot))) + client.changes.mockRejectedValue(missing()) + client.manifest.mockRejectedValue(missing()) + await expect(service.sync(localRoot)).rejects.toThrow() + await expect(readFile(savedStatePath(storageDirectory, localRoot))).rejects.toMatchObject({ code: 'ENOENT' }) + } + const recoveryFiles: string[] = [] + const collect = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name) + if (file === path.join(storageDirectory, 'states')) continue + if (entry.isDirectory()) await collect(file) + else if (entry.isFile()) recoveryFiles.push(file) + } + } + await collect(storageDirectory) + const archivedBytes = await Promise.all(recoveryFiles.map((file) => readFile(file))) + for (const snapshot of snapshots) { + expect(archivedBytes.filter((bytes) => bytes.equals(snapshot))).toHaveLength(1) + } + expect(await readFile(path.join(localRoot, 'Note.md'), 'utf8')).toBe('local version') + expect(await service.linkedVault(localRoot)).toBeNull() + }) + + it('clears a deleted association discovered by the background metadata probe', async () => { + const { service, client, localRoot } = await setup([cloudVault()]) + await service.link(localRoot, 'vault-1') + await service.sync(localRoot) + client.manifest.mockRejectedValue(missing()) + + await service.hasRemoteChanges(localRoot).catch(() => undefined) + + expect(await service.linkedVault(localRoot)).toBeNull() + client.manifest.mockClear() + expect(await service.hasRemoteChanges(localRoot)).toBe(false) + expect(client.manifest).not.toHaveBeenCalled() + }) + + it.each([ + ['offline', new Error('Network unavailable')], + ['unauthenticated', new CloudServiceRequestError('Sign in again', 401, 'UNAUTHENTICATED')], + ['forbidden', new CloudServiceRequestError('Access denied', 403, 'FORBIDDEN')], + ['server failure', new CloudServiceRequestError('Try later', 503, null)], + ['unstructured proxy 404', new CloudServiceRequestError('Not found', 404, null)] + ])('keeps the association and cursor after %s', async (_label, failure) => { + const { service, client, localRoot, storageDirectory } = await setup([cloudVault()]) + const link = await service.link(localRoot, 'vault-1') + await service.sync(localRoot) + const statePath = savedStatePath(storageDirectory, localRoot) + const state = await readFile(statePath) + client.changes.mockRejectedValue(failure) + client.manifest.mockRejectedValue(failure) + + await expect(service.sync(localRoot)).rejects.toThrow() + await service.hasRemoteChanges(localRoot).catch(() => undefined) + + expect(await service.linkedVault(localRoot)).toEqual(link) + expect(await readFile(statePath)).toEqual(state) + }) + + it('keeps a link when an individual resource is missing but its vault still exists', async () => { + const { service, client, localRoot } = await setup([cloudVault()]) + const link = await service.link(localRoot, 'vault-1') + await service.sync(localRoot) + await writeFile(path.join(localRoot, 'note.md'), 'Local note still exists') + client.mutate.mockRejectedValue(missing()) + // The authenticated vault-level confirmation still succeeds. A missing + // item, revision or upload must not be mistaken for a deleted vault. + client.manifest.mockResolvedValue({ data: [], cursor: 0, next_page: null }) + + await expect(service.sync(localRoot)).rejects.toThrow() + + expect(await service.linkedVault(localRoot)).toEqual(link) + expect(await readFile(path.join(localRoot, 'note.md'), 'utf8')).toBe('Local note still exists') + }) + + it('keeps the association if confirming a resource 404 fails due to authentication or network', async () => { + const { service, client, localRoot } = await setup([cloudVault()]) + const link = await service.link(localRoot, 'vault-1') + await service.sync(localRoot) + client.changes.mockRejectedValue(missing()) + client.manifest.mockRejectedValue(new CloudServiceRequestError('Sign in again', 401, 'UNAUTHENTICATED')) + + await expect(service.sync(localRoot)).rejects.toThrow() + + expect(await service.linkedVault(localRoot)).toEqual(link) + }) + + it('does not unlink a replacement association when an older sync finally reports deletion', async () => { + const { service, client, localRoot } = await setup([cloudVault(), cloudVault('vault-2')]) + await service.link(localRoot, 'vault-1') + await service.sync(localRoot) + let begin!: () => void + let fail!: (error: Error) => void + const started = new Promise((resolve) => { begin = resolve }) + client.changes.mockImplementationOnce(async () => { + begin() + return await new Promise((_resolve, reject) => { fail = reject }) + }) + client.manifest.mockRejectedValue(missing()) + const pending = service.sync(localRoot) + const rejected = expect(pending).rejects.toThrow() + await started + const replacement = await service.link(localRoot, 'vault-2') + fail(missing()) + await rejected + + expect(await service.linkedVault(localRoot)).toEqual(replacement) + }) +}) diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts index d1d4e395..eb9c9f6e 100644 --- a/apps/desktop/src/main/cloud-sync-service.ts +++ b/apps/desktop/src/main/cloud-sync-service.ts @@ -31,6 +31,7 @@ import { import { setVaultSettings } from './vault' import type { CloudSyncApiClient } from '@zennotes/shared-domain/cloud-sync-api' import { CloudServiceRequestError } from './cloud-sync-client' +import { CLOUD_VAULT_REMOVED_MESSAGE, confirmCloudVaultMissing, isCloudResourceMissing, sameCloudVaultLink } from '@zennotes/shared-domain/cloud-vault-availability' import { createDesktopCloudSyncCoordinator, DesktopCloudSyncStateStore } from './cloud-sync-filesystem' type SyncClient = Pick< @@ -73,6 +74,7 @@ export interface DesktopCloudSyncServiceDependencies { export class DesktopCloudSyncService { private readonly runs = new Map>() private readonly operations = new Map>() + private readonly linkUpdates = new Map>() private readonly now: () => Date private readonly fetchImplementation: typeof fetch @@ -158,7 +160,7 @@ export class DesktopCloudSyncService { } async unlink(localRoot: string): Promise { - await fs.rm(this.linkPath(localRoot), { force: true }) + await this.exclusive(path.resolve(localRoot), () => fs.rm(this.linkPath(localRoot), { force: true }), this.linkUpdates) } async deleteLinkedVault(localRoot: string): Promise { @@ -292,24 +294,46 @@ export class DesktopCloudSyncService { } async hasRemoteChanges(localRoot: string): Promise { - if (this.runs.has(path.resolve(localRoot))) return false - const link = await this.readLink(localRoot) - if (!link) return false - const connection = await this.optionalConnection() - if (!connection || link.base_url !== connection.account.base_url) return false - const states = new DesktopCloudSyncStateStore(path.join( - this.dependencies.storageDirectory, 'states', rootFingerprint(localRoot), - fingerprint(connection.account.base_url) - )) - const state = await states.load(link.vault_id) - if (!state) return true - // The changes endpoint includes file contents. A one-item manifest exposes - // the vault cursor without downloading an attachment just to detect it. - const manifest = await connection.client.manifest(link.vault_id, { - includeContent: false, - perPage: 1 + const key = path.resolve(localRoot) + if (this.runs.has(key)) return false + return this.exclusive(key, async () => { + const link = await this.readLink(localRoot) + if (!link) return false + const connection = await this.optionalConnection() + if (!connection || link.base_url !== connection.account.base_url) return false + const states = this.stateStore(localRoot, link) + const state = await states.load(link.vault_id) + if (!state) return true + try { + const manifest = await connection.client.manifest(link.vault_id, { + includeContent: false, + perPage: 1 + }) + return manifest.cursor !== state.cursor + } catch (error) { + if (isCloudResourceMissing(error) && await this.retireMissingLink(localRoot, link)) { + throw new Error(CLOUD_VAULT_REMOVED_MESSAGE) + } + throw error + } }) - return manifest.cursor !== state.cursor + } + + private stateStore(localRoot: string, link: CloudVaultLink): DesktopCloudSyncStateStore { + return new DesktopCloudSyncStateStore(path.join( + this.dependencies.storageDirectory, 'states', rootFingerprint(localRoot), fingerprint(link.base_url) + )) + } + + private retireMissingLink(localRoot: string, link: CloudVaultLink): Promise { + return this.exclusive(path.resolve(localRoot), async () => { + if (!sameCloudVaultLink(await this.readLink(localRoot), link)) return false + await this.stateStore(localRoot, link).retire(link.vault_id, path.join( + this.dependencies.storageDirectory, 'retired-states', rootFingerprint(localRoot), fingerprint(link.base_url) + )) + await fs.rm(this.linkPath(localRoot), { force: true }) + return true + }, this.linkUpdates) } sync(localRoot: string): Promise { @@ -344,16 +368,24 @@ export class DesktopCloudSyncService { vaultId: link.vault_id, remote: client }) - const result = await coordinator.sync() - return { - cursor: result.state.cursor, - pulled: result.pulled, - pushed: result.pushed, - conflicts: result.conflicts, - bootstrap_conflicts: result.bootstrapConflicts, - local_conflicts: result.localConflicts, - pending_conflicts: result.pendingConflicts, - legacy_conflict_copies: result.legacyConflictCopies + try { + const result = await coordinator.sync() + return { + cursor: result.state.cursor, + pulled: result.pulled, + pushed: result.pushed, + conflicts: result.conflicts, + bootstrap_conflicts: result.bootstrapConflicts, + local_conflicts: result.localConflicts, + pending_conflicts: result.pendingConflicts, + legacy_conflict_copies: result.legacyConflictCopies + } + } catch (error) { + if (await confirmCloudVaultMissing(client, link.vault_id, error) && + await this.retireMissingLink(localRoot, link)) { + throw new Error(CLOUD_VAULT_REMOVED_MESSAGE) + } + throw error } } @@ -458,15 +490,15 @@ export class DesktopCloudSyncService { }) } - private exclusive(key: string, operation: () => Promise): Promise { - const previous = this.operations.get(key) + private exclusive(key: string, operation: () => Promise, operations = this.operations): Promise { + const previous = operations.get(key) let current!: Promise current = (previous ? previous.catch(() => undefined) : Promise.resolve()) .then(operation) .finally(() => { - if (this.operations.get(key) === current) this.operations.delete(key) + if (operations.get(key) === current) operations.delete(key) }) - this.operations.set(key, current) + operations.set(key, current) return current } @@ -562,11 +594,13 @@ export class DesktopCloudSyncService { } private async writeLink(localRoot: string, link: CloudVaultLink): Promise { - const target = this.linkPath(localRoot) - const temporary = `${target}.${process.pid}.${randomUUID()}.tmp` - await fs.mkdir(path.dirname(target), { recursive: true }) - await fs.writeFile(temporary, JSON.stringify(link, null, 2), { encoding: 'utf8', mode: 0o600 }) - await fs.rename(temporary, target) + await this.exclusive(path.resolve(localRoot), async () => { + const target = this.linkPath(localRoot) + const temporary = `${target}.${process.pid}.${randomUUID()}.tmp` + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(temporary, JSON.stringify(link, null, 2), { encoding: 'utf8', mode: 0o600 }) + await fs.rename(temporary, target) + }, this.linkUpdates) } private linkPath(localRoot: string): string { diff --git a/apps/desktop/src/main/databases.test.ts b/apps/desktop/src/main/databases.test.ts index 205db6cf..4a40089b 100644 --- a/apps/desktop/src/main/databases.test.ts +++ b/apps/desktop/src/main/databases.test.ts @@ -10,6 +10,8 @@ import { writeDatabaseRows } from './databases' +import { getVaultSettings, setVaultSettings, invalidateVaultSettingsCache, writeNoteComments, readNoteComments } from './vault' + const tmpDirs: string[] = [] async function makeVault(): Promise { const dir = await mkdtemp(path.join(os.tmpdir(), 'zennotes-db-')) @@ -107,6 +109,26 @@ describe('renameDatabase', () => { }) }) + +describe('database rename comments', () => { + it.each(['inbox', 'root'] as const)('moves record comments with custom folder settings in %s mode', async (location) => { + const root = await makeVault() + await setVaultSettings(root, { ...await getVaultSettings(root), primaryNotesLocation: location, systemFolderPaths: { inbox: 'My Notes' }, folderIcons: { 'inbox:Work/People.base': 'book' }, folderColors: { 'inbox:Work/People.base': 'blue' } }) + const doc = await createDatabase(root, 'inbox', 'Work', 'People') + const page = await createRecordPage(root, doc.path, 'Record', 'Record body.') + await writeNoteComments(root, page, [{ notePath: page, anchorStart: 0, anchorEnd: 6, anchorText: 'Record', body: 'Keep comment' }]) + const renamed = await renameDatabase(root, doc.path, 'Customers') + const nextPage = renamed.replace('data.csv', 'Record.md') + expect(await readNoteComments(root, nextPage)).toMatchObject([{ notePath: nextPage, body: 'Keep comment' }]) + expect(await readNoteComments(root, page)).toEqual([]) + invalidateVaultSettingsCache(root) + const settings = await getVaultSettings(root) + expect(settings.folderIcons['inbox:Work/Customers.base']).toBe('book') + expect(settings.folderColors['inbox:Work/Customers.base']).toBe('blue') + expect(settings.folderIcons['inbox:Work/People.base']).toBeUndefined() + }) +}) + describe('adopting a plain CSV (no sidecar)', () => { it('infers schema, materializes the sidecar + stable ids, and is stable on re-read', async () => { const root = await makeVault() diff --git a/apps/desktop/src/main/databases.ts b/apps/desktop/src/main/databases.ts index 06fea523..4817fdad 100644 --- a/apps/desktop/src/main/databases.ts +++ b/apps/desktop/src/main/databases.ts @@ -34,6 +34,10 @@ import { databaseDataPath, databaseSidecarPath, folderRoot, + folderForRelativePath, + getVaultSettings, + renameFolder, + renameFolderTrees, sanitizeNoteTitle, uniqueTitle, writeFileAtomic @@ -330,7 +334,7 @@ export async function deleteDatabase(root: string, csvRel: string): Promise.base` folder to * `.base` (non-colliding). Returns the new `data.csv` path. Because the - * data, schema, and pages all live inside, nothing else needs rewriting. + * data, schema, and pages move together; the parallel comment tree follows too. */ export async function renameDatabase( root: string, @@ -354,7 +358,17 @@ export async function renameDatabase( break } } - await fs.rename(databaseDataPath(root, formDir), databaseDataPath(root, targetRel)) + const settings = await getVaultSettings(root) + const folder = folderForRelativePath(formDir, settings) + const top = folder ? await folderRoot(root, folder) : null + const oldSub = top ? toPosix(path.relative(top, databaseDataPath(root, formDir))) : null + if (folder && top && oldSub && oldSub !== '..' && !oldSub.startsWith('../')) { + const newSub = toPosix(path.relative(top, databaseDataPath(root, targetRel))) + await renameFolder(root, folder, oldSub, newSub) + } else { + // Existing root-level databases remain accessible even in inbox mode. + await renameFolderTrees(root, formDir, targetRel) + } return csvPathForFormDir(targetRel) } diff --git a/apps/desktop/src/main/demo-tour-data.ts b/apps/desktop/src/main/demo-tour-data.ts index 039aad83..fe09723b 100644 --- a/apps/desktop/src/main/demo-tour-data.ts +++ b/apps/desktop/src/main/demo-tour-data.ts @@ -1,82 +1,2 @@ -export interface DemoTourTemplateFile { - path: string - body: string -} - -export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ - { - path: "inbox/demo/00 — Start Here.md", - body: "# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local files\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n" - }, - { - path: "inbox/demo/01 — Markdown Basics.md", - body: "# Markdown basics\n\nZenNotes starts with ordinary markdown. The app adds keyboard-first workflows around it, but the source stays portable and readable everywhere.\n\n## Headings\n\n```\n# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n```\n\nHeadings matter for more than styling:\n\n- they show up in the **outline**\n- they can be folded with `zc` and unfolded with `zo`\n- long notes can be searched by heading with `Space p`\n\n## Emphasis\n\n*Italic* with single asterisks, **bold** with double, ***bold italic*** with triple, `inline code` with backticks, ~~strikethrough~~ with tildes, and ==highlight== with double equals.\n\n## Paragraphs and line breaks\n\nA blank line starts a new paragraph.\nA single newline usually stays in the same paragraph.\n\nLeave two trailing spaces when you really want a hard line break. \nLike this.\n\n## Lists\n\nUnordered:\n\n- Apples\n- Bananas\n - Cavendish\n - Plantain\n- Cherries\n\nOrdered:\n\n1. Draft the note\n2. Refine the structure\n3. Ship the change\n\n## Links\n\n- External: [ZenNotes](https://lumarylabs.com)\n- Autolink: \n- Wikilink: [[07 — Wiki Links and Tags]]\n- Custom label: [[11 — Workspace, Search, and Views|workspace guide]]\n\n## Blockquotes and dividers\n\n> Markdown still does a lot with very little.\n>\n> ZenNotes just makes it faster to navigate and work with.\n\n---\n\n## Frontmatter\n\nYAML frontmatter works fine at the top of a note:\n\n```yaml\n---\ntitle: My Note\ndate: 2026-04-16\ntags: [project, research]\npriority: high\n---\n```\n\nZenNotes does not require frontmatter, but features like daily notes, tags, and task defaults can make use of it.\n\n## Slash commands\n\nZenNotes also helps you write these structures faster:\n\n- type `/` at the start of a line or after whitespace\n- choose items like headings, bullets, numbered lists, tasks, callouts, code blocks, tables, math blocks, links, images, and dividers\n- keep typing after `/` to filter the insert menu\n\nThat means markdown stays plain, but you do not have to remember every snippet from scratch.\n\n## What to try in this note\n\n- Put the cursor on a heading and fold it.\n- Switch the note between **Edit**, **Split**, and **Preview**.\n- Open the outline with `Space p`.\n- Search for this note with `Space f`.\n\n## What's next\n\nJump to [[02 — Code Blocks]] for syntax highlighting, [[06 — Callouts and Footnotes]] for richer block styles, or back to [[00 — Start Here]].\n\n#demo #markdown\n" - }, - { - path: "inbox/demo/02 — Code Blocks.md", - body: "# Code blocks\n\nZenNotes treats code fences as plain markdown on disk and renders them with syntax highlighting in preview and split view.\n\n## A fast way to insert them\n\nType `/` and choose **Code block** if you do not want to type the fence manually.\n\n## TypeScript\n\n```ts\nexport interface User {\n id: string\n name: string\n roles: string[]\n}\n\nexport async function fetchUser(id: string): Promise {\n const response = await fetch(`/api/users/${id}`)\n if (!response.ok) return null\n return (await response.json()) as User\n}\n```\n\n## Python\n\n```python\nfrom dataclasses import dataclass\n\n@dataclass\nclass Point:\n x: float\n y: float\n\n def distance_to(self, other: \"Point\") -> float:\n return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5\n```\n\n## Bash\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nfor note in inbox/*.md; do\n words=$(wc -w < \"$note\")\n printf \"%6d %s\\n\" \"$words\" \"$(basename \"$note\")\"\ndone\n```\n\n## Rust\n\n```rust\nuse std::collections::HashMap;\n\nfn word_count(text: &str) -> HashMap {\n let mut counts = HashMap::new();\n for word in text.split_whitespace() {\n *counts.entry(word.to_lowercase()).or_insert(0) += 1;\n }\n counts\n}\n```\n\n## JSON\n\n```json\n{\n \"name\": \"ZenNotes\",\n \"productName\": \"ZenNotes\",\n \"version\": \"0.1.0\",\n \"scripts\": {\n \"dev\": \"electron-vite dev\",\n \"build\": \"electron-vite build\"\n }\n}\n```\n\n## Diff\n\n```diff\n- Space /\n+ Space s t\n```\n\n## Plain text\n\n```\nNo language tag, no syntax highlighting.\nUseful for raw config examples or ASCII notes.\n```\n\n## Inline code\n\nUse `inline code` when the snippet belongs inside a sentence.\n\n## Workflow notes\n\n- **Edit** mode is best for writing or refactoring the raw fence.\n- **Split** mode is ideal when you want source on one side and highlighted output on the other.\n- Fenced blocks are ignored by the task scanner, so `- [ ]` inside code stays an example, not a live task.\n- Vault text search can still find matching text inside code fences because they are part of the note body.\n\n## What's next\n\nSee [[05 — Mermaid Diagrams]] for Mermaid fences, [[05b — Math Diagrams]] for TikZ, JSXGraph, and function-plot, or [[10 — Ideas and Tasks]] for how snippets mix with prose and planning in a real note.\n\n#demo #code\n" - }, - { - path: "inbox/demo/03 — Tables and Task Lists.md", - body: "# Tables and task lists\n\n## Tables\n\nPlain GFM tables. Alignment is controlled with colons in the divider row.\n\n| Feature | Support | Notes |\n| ---------- | :--------: | --------------------------------------------------------- |\n| Headings | ✅ | Fold from the editor gutter and jump via the outline. |\n| Wiki links | ✅ | `[[Title]]` resolves by note name. |\n| Tags | ✅ | Written inline as `#like-this`. |\n| Math | ✅ | KaTeX, inline and display. |\n| Mermaid | ✅ | Rendered inside preview and split view. |\n| Search | ✅ | Notes by title/path, vault text by fuzzy content search. |\n| Sync | File-based | Use any sync tool that watches folders. |\n\nRight-aligned numbers:\n\n| Quarter | Revenue | Delta |\n| ------: | -------: | -----: |\n| Q1 | $124,300 | +4.2% |\n| Q2 | $131,980 | +6.2% |\n| Q3 | $129,010 | −2.3% |\n| Q4 | $152,407 | +18.1% |\n\n## Task lists\n\nEvery checkbox survives on disk as normal markdown like `- [ ]` and `- [x]`.\n\n## What ZenNotes task parsing supports\n\n### Core checkboxes\n\n- [ ] Open task\n- [x] Completed task\n- [X] Uppercase `X` also counts as completed\n\n### Different list styles still count\n\n- [ ] Bulleted task using `-`\n+ [ ] Bulleted task using `+`\n* [ ] Bulleted task using `*`\n1. [ ] Ordered task using `1.`\n2) [ ] Ordered task using `2)`\n> - [ ] Blockquoted task lines are parsed too\n\n### Nested tasks\n\n- [ ] Weekly review\n - [ ] Clear inbox to zero\n - [ ] Triage [[10 — Ideas and Tasks]]\n - [x] Back up vault\n - [ ] Plan next week\n - [ ] Monday — design review\n - [ ] Tuesday — code-freeze prep\n - [x] Saturday — offline\n\n### Metadata tokens on the task line\n\n- [ ] Ship the onboarding checklist due:2026-04-18 !high #onboarding #docs\n- [ ] Refresh demo screenshots due:2026-04-22 !med #demo #assets\n- [ ] Clean up seed notes !low #maintenance\n- [ ] Wait for design sign-off @waiting #design\n- [ ] Review vault search UX due:2026-04-30 !high #search #ux\n\nThe parser understands these tokens:\n\n| Token | Meaning | Example |\n| ----- | ------- | ------- |\n| `due:YYYY-MM-DD` | ISO due date used for grouping | `due:2026-04-22` |\n| `!high` / `!med` / `!low` | Priority marker | `!high` |\n| `@waiting` | Moves the task into the Waiting group | `@waiting` |\n| `#tag` | Inline task tag, searchable in the Tasks view | `#design` |\n\n### What the Tasks view does with them\n\n- Tasks with no due date land in **Today**\n- Tasks due today or already overdue also land in **Today**\n- Tasks due in the future land in **Upcoming**\n- Tasks with `@waiting` land in **Waiting**\n- Checked tasks land in **Done**\n- Overdue tasks contribute to the overdue count in the **Today** section\n\n### Filtering and navigation\n\nPress the sidebar **Tasks** row to scan every live note across **Inbox**, **Quick Notes**, and **Archive**. From there you can:\n\n- filter by task content\n- filter by note title\n- filter by inline `#tags`\n- filter by priority markers like `!high`\n- press `Enter` or `o` to open the source note\n- press `Space` or `x` to toggle the selected task without leaving the list\n\n### Ignored on purpose\n\nTasks inside fenced code blocks are not parsed, so you can document task syntax safely:\n\n```md\n- [ ] This looks like a task\n- [x] But code fences are ignored by the vault-wide task scanner\n- [ ] That makes examples and snippets safe\n```\n\n### Note-level defaults\n\nYou can also set due date and priority defaults in frontmatter, then override them inline per task:\n\n```yaml\n---\ndue: 2026-05-01\npriority: high\n---\n```\n\nWith defaults like that, a plain line such as `- [ ] Draft roadmap` inherits the due date and priority even without repeating the tokens.\n\n### Rendering checklist\n\nEvery item below is wired up:\n\n- [x] Paragraphs\n- [x] Emphasis: _italic_, **bold**, ~~strike~~\n- [x] Ordered and unordered lists\n- [x] Tables\n- [x] Task lists\n- [x] Blockquotes\n- [x] Footnotes (see [[06 — Callouts and Footnotes]])\n- [x] Math blocks (see [[04 — Math with KaTeX]])\n- [x] Mermaid (see [[05 — Mermaid Diagrams]])\n- [x] TikZ, JSXGraph, and function-plot (see [[05b — Math Diagrams]])\n- [x] Vault-wide Tasks grouping and filtering\n- [ ] Screenshots in the tour due:2026-04-25 !med #docs\n\n## Tasks as an app feature\n\nThe Tasks tab is not just a renderer demo. It is a vault-wide operational view for planning and review. Use it when you want one place to see what is due, what is waiting, what is done, and where each task lives.\n\n#demo #tasks #tables\n" - }, - { - path: "inbox/demo/04 — Math with KaTeX.md", - body: "# Math with KaTeX\n\nZenNotes renders LaTeX math via KaTeX. The source stays plain markdown while preview and split mode give you readable math output.\n\n## A fast way to insert math\n\nType `/` and choose **Math block** when you want display math without typing the fence from memory.\n\n## Inline math\n\nEuler's identity is $e^{i\\pi} + 1 = 0$. \nThe area of a circle is $A = \\pi r^2$. \nA quadratic has roots $x = \\dfrac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$.\n\n## Display blocks\n\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\, dx = \\sqrt{\\pi}\n$$\n\n$$\n\\frac{\\partial}{\\partial t} \\Psi(x, t) = -\\frac{\\hbar^2}{2m} \\frac{\\partial^2}{\\partial x^2} \\Psi(x, t) + V(x)\\Psi(x, t)\n$$\n\n## Aligned equations\n\n$$\n\\begin{aligned}\n(a + b)^2 &= a^2 + 2ab + b^2 \\\\\n(a - b)^2 &= a^2 - 2ab + b^2 \\\\\na^2 - b^2 &= (a + b)(a - b)\n\\end{aligned}\n$$\n\n## Matrices\n\n$$\n\\mathbf{A} =\n\\begin{bmatrix}\n 1 & 2 & 3 \\\\\n 4 & 5 & 6 \\\\\n 7 & 8 & 9\n\\end{bmatrix}\n\\qquad\n\\det(\\mathbf{A}) = 0\n$$\n\n## Summations, limits, derivatives\n\n$$\n\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}\n\\qquad\n\\lim_{x \\to 0} \\frac{\\sin x}{x} = 1\n\\qquad\n\\frac{d}{dx} \\ln x = \\frac{1}{x}\n$$\n\n## Probability and finance\n\n$$\nP(A \\mid B) = \\frac{P(B \\mid A) P(A)}{P(B)}\n$$\n\n$$\nC = S_0 \\Phi(d_1) - K e^{-rT} \\Phi(d_2)\n$$\n\n$$\nd_1 = \\frac{\\ln(S_0 / K) + (r + \\tfrac{1}{2}\\sigma^2) T}{\\sigma \\sqrt{T}}, \\qquad d_2 = d_1 - \\sigma \\sqrt{T}\n$$\n\n## Why this matters in ZenNotes\n\n- **Edit** mode keeps the raw LaTeX visible.\n- **Split** mode is great when you want source and rendered math side by side.\n- **Preview** mode turns math-heavy notes into something closer to a paper or spec.\n- Vault text search still sees the underlying source, which makes formulas searchable as text.\n\n## Prefer Typst? An alternative math engine\n\nZenNotes can also typeset math with **Typst** instead of KaTeX. Open **Settings ▸ Editor ▸ Math renderer** and pick **Typst**; it applies in both the live editor and the reading view.\n\nTypst reads the same `$…$` and `$$…$$` blocks as **Typst markup**, not LaTeX, so each note's math is written for whichever engine you pick. The formulas here are Typst syntax: with the Math renderer set to **Typst** they render; with **KaTeX** (the default) they show as errors until you switch.\n\nInline: $x^2 + y^2 = z^2$ and $sqrt(a^2 + b^2)$.\n\n$$\nintegral_0^1 x^2 dif x = 1/3\n$$\n\n$$\nsum_(n=1)^oo 1/n^2 = pi^2/6\n$$\n\n$$\nmat(1, 2; 3, 4) quad vec(a, b, c)\n$$\n\n## What's next\n\nWhen the note needs geometry, plotted functions, or figure-quality diagrams rather than equation layout, jump to [[05b — Math Diagrams]].\n\n#demo #math #reference\n" - }, - { - path: "inbox/demo/05 — Mermaid Diagrams.md", - body: "# Mermaid diagrams\n\nMermaid fences render inline in ZenNotes. They are still just markdown code blocks on disk, so you can version them, diff them, and edit them anywhere.\n\nFor TikZ, JSXGraph, and function-plot, see [[05b — Math Diagrams]].\n\n## A fast way to insert one\n\nType `/` and choose **Code block**, then change the language to `mermaid`.\n\n## Flowchart\n\n```mermaid\nflowchart LR\n A([User types]) --> B{Vim mode?}\n B -- yes --> C[CodeMirror vim keymap]\n B -- no --> D[Standard editing]\n C --> E[Save to .md]\n D --> E\n E --> F([File on disk])\n```\n\n## Sequence diagram\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant R as Renderer\n participant M as Main process\n participant D as Disk\n\n U->>R: Type in editor\n R->>M: writeNote(path, body)\n M->>D: fs.writeFile(...)\n D-->>M: ok\n M-->>R: NoteMeta\n R-->>U: Clean tab title\n```\n\n## State diagram\n\n```mermaid\nstateDiagram-v2\n [*] --> Draft\n Draft --> Review : Submit\n Review --> Draft : Request changes\n Review --> Approved : Accept\n Approved --> Published : Ship\n Published --> Archived : 90 days\n Archived --> [*]\n```\n\n## Gantt chart\n\n```mermaid\ngantt\n title Product roadmap\n dateFormat YYYY-MM-DD\n axisFormat %b %d\n\n section Editor\n Vim motions polish :done, vim1, 2026-03-10, 5d\n Outline panel :done, out1, 2026-03-17, 3d\n Attachments preview :active, att1, 2026-04-15, 7d\n Multi-window sync : mws1, after att1, 5d\n\n section Release\n QA pass : qa1, after mws1, 3d\n Ship :milestone, rel1, after qa1, 0d\n```\n\n## Pie chart\n\n```mermaid\npie title How the day was spent\n \"Deep work\" : 45\n \"Meetings\" : 15\n \"Slack\" : 10\n \"Reading\" : 20\n \"Breaks\" : 10\n```\n\n## Vault map\n\n```mermaid\ngraph TB\n subgraph Lifecycle\n Q[Quick Notes]\n I[Inbox]\n A[Archive]\n T[Trash]\n end\n Q --> I\n I --> A\n I --> T\n A --> I\n T --> I\n```\n\n## Working with diagrams in the app\n\n- **Split** mode is usually the sweet spot: raw source on one side, rendered diagram on the other.\n- Diagrams are still searchable because the source fence lives in the note body.\n- If Mermaid syntax breaks, ZenNotes falls back to showing the source block, which makes failures debuggable instead of mysterious.\n\n## What's next\n\nStay in diagram mode with [[05b — Math Diagrams]] if you want interactive geometry, coordinate figures, or compact function plots.\n\n#demo #mermaid #diagrams\n" - }, - { - path: "inbox/demo/05b — Math Diagrams.md", - body: "# Math diagrams — TikZ, JSXGraph, and function-plot\n\nBeyond Mermaid (see [[05 — Mermaid Diagrams]]) and KaTeX (see [[04 — Math with KaTeX]]), ZenNotes renders three more diagram types from plain fenced code blocks. Each one shines at a different job.\n\nSwitch to **Preview** or **Split** mode to see them rendered. The source stays plain markdown on disk.\n\n---\n\n## TikZ — figure-quality math diagrams\n\nUse when you want paper-grade vector figures: coordinate systems, geometry, commutative diagrams, automata, trees, plots. The full TikZ + pgfplots toolchain compiles on-device via WebAssembly — no network, no LaTeX install.\n\n### A parabola with axes\n\n```tikz\n\\begin{tikzpicture}\n \\draw[->, thick] (-2.2,0) -- (2.2,0) node[right] {$x$};\n \\draw[->, thick] (0,-0.5) -- (0,4.5) node[above] {$y$};\n \\draw[domain=-2:2, smooth, thick, blue] plot (\\x,{\\x*\\x});\n \\node[blue, above right] at (1.4, 1.96) {$y = x^2$};\n\\end{tikzpicture}\n```\n\n### A triangle with labelled vertices\n\n```tikz\n\\begin{tikzpicture}\n \\coordinate[label=below left:$A$] (A) at (0,0);\n \\coordinate[label=below right:$B$] (B) at (4,0);\n \\coordinate[label=above:$C$] (C) at (1.5,3);\n \\draw[thick] (A) -- (B) -- (C) -- cycle;\n \\draw[dashed] (C) -- ($ (A)!(C)!(B) $) node[pos=0.5, right] {$h$};\n\\end{tikzpicture}\n```\n\n### A small commutative diagram\n\n```tikz\n\\begin{tikzpicture}[node distance=2.2cm, every node/.style={font=\\small}]\n \\node (A) {$A$};\n \\node (B) [right of=A] {$B$};\n \\node (C) [below of=A] {$C$};\n \\node (D) [right of=C] {$D$};\n \\draw[->] (A) -- node[above] {$f$} (B);\n \\draw[->] (A) -- node[left] {$g$} (C);\n \\draw[->] (B) -- node[right] {$h$} (D);\n \\draw[->] (C) -- node[below] {$k$} (D);\n\\end{tikzpicture}\n```\n\n---\n\n## JSXGraph — interactive geometry and plots\n\nUse when you want the diagram to be **draggable** and **live**. Points move, sliders animate, curves reflow. Configuration is a small JSON object — no JavaScript required.\n\nEach object takes a `type` (the JSXGraph element name) and `args` (the element's constructor arguments). Assign an `id` to reference an object from a later one using `\"@id\"` — useful for attaching points to curves, for example.\n\n### Sine wave with a point on the curve\n\nJSXGraph's `functiongraph` evaluates string expressions with its built-in **JessieCode** parser — so write `sin(x)`, `cos(x)`, `x^2`, `exp(x)`, etc. directly (no `Math.` prefix).\n\n```jsxgraph\n{\n \"boundingbox\": [-6.5, 1.6, 6.5, -1.6],\n \"axis\": true,\n \"objects\": [\n {\n \"id\": \"curve\",\n \"type\": \"functiongraph\",\n \"args\": [\"sin(x)\"],\n \"attributes\": { \"strokeColor\": \"#6caedf\", \"strokeWidth\": 2 }\n },\n {\n \"type\": \"glider\",\n \"args\": [1, 0, \"@curve\"],\n \"attributes\": {\n \"name\": \"P\",\n \"size\": 4,\n \"strokeColor\": \"#d35e0c\",\n \"fillColor\": \"#d35e0c\"\n }\n }\n ]\n}\n```\n\nDrag `P` along the curve.\n\n### Unit circle with a labelled point\n\n```jsxgraph\n{\n \"boundingbox\": [-1.6, 1.6, 1.6, -1.6],\n \"axis\": true,\n \"width\": 360,\n \"height\": 360,\n \"objects\": [\n {\n \"type\": \"circle\",\n \"args\": [[0, 0], 1],\n \"attributes\": { \"strokeColor\": \"#945e80\" }\n },\n {\n \"type\": \"point\",\n \"args\": [0.7, 0.7141],\n \"attributes\": {\n \"name\": \"Q\",\n \"fillColor\": \"#6c782e\",\n \"strokeColor\": \"#6c782e\"\n }\n }\n ]\n}\n```\n\n### Two lines and their intersection\n\n```jsxgraph\n{\n \"boundingbox\": [-5, 5, 5, -5],\n \"axis\": true,\n \"objects\": [\n { \"id\": \"A\", \"type\": \"point\", \"args\": [-3, -2], \"attributes\": { \"name\": \"A\" } },\n { \"id\": \"B\", \"type\": \"point\", \"args\": [ 3, 2], \"attributes\": { \"name\": \"B\" } },\n { \"id\": \"C\", \"type\": \"point\", \"args\": [-3, 2], \"attributes\": { \"name\": \"C\" } },\n { \"id\": \"D\", \"type\": \"point\", \"args\": [ 3, -2], \"attributes\": { \"name\": \"D\" } },\n {\n \"id\": \"L1\",\n \"type\": \"line\",\n \"args\": [\"@A\", \"@B\"],\n \"attributes\": { \"strokeColor\": \"#45707a\" }\n },\n {\n \"id\": \"L2\",\n \"type\": \"line\",\n \"args\": [\"@C\", \"@D\"],\n \"attributes\": { \"strokeColor\": \"#c14a4a\" }\n },\n {\n \"type\": \"intersection\",\n \"args\": [\"@L1\", \"@L2\", 0],\n \"attributes\": { \"name\": \"X\", \"size\": 4, \"fillColor\": \"#b47109\" }\n }\n ]\n}\n```\n\nDrag any of `A`–`D` and the intersection follows.\n\n---\n\n## function-plot — quick Cartesian plots\n\nSmallest and simplest of the three. Give it functions, get a plot. Great for calculus-style notes and quick sanity checks.\n\nThe fence body is the options object passed to [function-plot](https://mauriciopoppe.github.io/function-plot/). Expression syntax is standard JavaScript math — `Math.PI`, `Math.sin(x)`, etc. — plus the `x^2` shorthand for powers.\n\n### Several functions on one axis\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"xAxis\": { \"domain\": [-6.28, 6.28] },\n \"grid\": true,\n \"data\": [\n { \"fn\": \"sin(x)\", \"color\": \"#45707a\" },\n { \"fn\": \"cos(x)\", \"color\": \"#c14a4a\" },\n { \"fn\": \"x / 3.14159265\", \"color\": \"#6c782e\" }\n ]\n}\n```\n\n### A derivative annotation\n\nHover the curve — the tangent slope updates live.\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-2, 8] },\n \"xAxis\": { \"domain\": [-3, 3] },\n \"grid\": true,\n \"data\": [\n {\n \"fn\": \"x^2\",\n \"derivative\": { \"fn\": \"2 * x\", \"updateOnMouseMove\": true },\n \"color\": \"#945e80\"\n }\n ]\n}\n```\n\n### A parametric curve\n\n```function-plot\n{\n \"xAxis\": { \"domain\": [-1.5, 1.5] },\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"grid\": true,\n \"data\": [\n {\n \"graphType\": \"polyline\",\n \"fnType\": \"parametric\",\n \"x\": \"cos(t)\",\n \"y\": \"sin(t)\",\n \"range\": [0, 6.283],\n \"color\": \"#b47109\"\n }\n ]\n}\n```\n\n---\n\n## When to reach for which\n\n| You want… | Use |\n| ---------------------------------------------------------------- | ------------------------------------------- |\n| Paper-grade static figure, TikZ muscle-memory, LaTeX portability | **TikZ** |\n| Interactive geometry, draggable points, geometry theorems | **JSXGraph** |\n| Quick plot of a few functions, minimal config | **function-plot** |\n| Flow / sequence / state / gantt / ER diagram | **Mermaid** (see [[05 — Mermaid Diagrams]]) |\n| Inline formulas, display equations | **KaTeX** (see [[04 — Math with KaTeX]]) |\n\n#demo #math #diagrams #tikz #jsxgraph #function-plot\n" - }, - { - path: "inbox/demo/06 — Callouts and Footnotes.md", - body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" - }, - { - path: "inbox/demo/07 — Wiki Links and Tags.md", - body: "# Wiki links, tags, backlinks, and search\n\nThese features turn a folder of markdown files into a navigable vault.\n\n## Wiki links\n\nPoint at other notes with `[[double brackets]]`. ZenNotes resolves them by note title, case-insensitively.\n\n- Shortest form: [[01 — Markdown Basics]]\n- Custom display text: [[11 — Workspace, Search, and Views|workspace guide]]\n- Missing note: [[A Future Note]] — opening it offers to create the note\n\nYou can follow links with the mouse or keyboard:\n\n- in Vim mode, put the cursor on a link and press `gd`\n- markdown links and wikilinks both work\n- PDFs can open directly into the reference pane\n\n## Tags\n\nTags are plain inline text. They start with `#` and become searchable structure.\n\nThis demo folder uses tags like:\n\n- #demo\n- #reference\n- #tasks\n- #vim\n- #search\n- #workspace\n\nThe **Tags** view lets you browse notes matching one or more selected tags in a dedicated main-pane list.\n\n## Connections\n\nThe **Connections** panel helps you inspect:\n\n- outbound links from the current note\n- backlinks into the current note\n- unresolved link targets that still need a note\n\nThis is especially useful when you are writing specs, research notes, or project docs and want context without leaving the active note.\n\n## Search modes\n\nZenNotes has two distinct searches:\n\n### Note search\n\n- `⌘P` opens the note search palette\n- `Space f` opens the same search in Vim mode\n- this search matches note titles and paths\n\n### Vault text search\n\n- `Space s t` opens vault text search\n- it searches matching text lines across **Inbox**, **Quick Notes**, and **Archive**\n- selecting a result opens the note and jumps to the matched line\n\nVault text search can run on different backends:\n\n- **Auto** prefers `fzf`, then `ripgrep`, then built-in\n- **Built-in** keeps everything inside ZenNotes\n- **ripgrep** and **fzf** can be chosen explicitly\n- custom binary paths can be configured in **Settings**\n- the app shows the resolved runtime backend so you can see what is actually being used\n\n## Graph of this tour\n\n```mermaid\ngraph LR\n A[[00 — Start Here]]\n A --> B[[01 — Markdown Basics]]\n A --> C[[02 — Code Blocks]]\n A --> D[[03 — Tables and Task Lists]]\n A --> E[[04 — Math with KaTeX]]\n A --> F[[05 — Mermaid Diagrams]]\n A --> G[[05b — Math Diagrams]]\n A --> H[[06 — Callouts and Footnotes]]\n A --> I[[07 — Wiki Links and Tags]]\n A --> J[[08 — Daily Notes]]\n A --> K[[09 — Vim Cheat Sheet]]\n A --> L[[10 — Ideas and Tasks]]\n A --> M[[11 — Workspace, Search, and Views]]\n A --> N[[12 — Settings and Keymaps]]\n A --> O[[13 — Commands, Help, and Demo Tour]]\n A --> P[[14 — Reference Pane and Floating Windows]]\n A --> Q[[15 — Search Backends and Fuzzy Workflows]]\n```\n\n#demo #reference #search #links\n" - }, - { - path: "inbox/demo/08 — Daily Notes.md", - body: "---\ntitle: 2026-04-16\ndate: 2026-04-16\ntags: [daily, log, demo]\n---\n\n# Thursday, 2026-04-16\n\n> [!tip] Pattern\n> A daily note is still just a `.md` file. Keep it under `inbox/daily/`, `quick/`, or wherever your vault makes sense. If you name it `YYYY-MM-DD.md`, it sorts chronologically without extra tooling.\n\n## Why daily notes fit ZenNotes well\n\n- they stay file-based and sync-friendly\n- they pair naturally with quick capture\n- they work well with tasks, tags, and links\n- reopening the app restores your tabs, panes, and window bounds, so an active daily workflow is easy to resume\n\n## Agenda\n\n- [ ] Morning: triage [[10 — Ideas and Tasks]]\n- [ ] 10:00 — design review\n- [ ] 12:00 — lunch\n- [x] 14:00 — code-freeze prep\n- [ ] Evening: reading — Seeing Like a State, chapter 3\n\n## Quick capture and dates\n\nQuick Notes are for fast capture. From there you can:\n\n- keep the note in Quick Notes\n- move it into Inbox\n- archive it later\n- trash it with confirmation if it is no longer useful\n\nDate helpers are also built in:\n\n- type `@` to insert **Today**, **Yesterday**, or **Tomorrow**\n- the inserted value is an ISO date like `2026-04-16`\n- ISO dates stay readable, sortable, and easy to search\n\nExamples:\n\n- Review due @today\n- Follow up on search backend docs @tomorrow\n- Closed the previous thread @yesterday\n\n## Log\n\n- Shipped the vault text search backend picker.\n- Updated the demo vault so it covers the current product surface.\n- Verified that session restore brings back the working layout after relaunch.\n\n## Wins\n\n- The same note works in edit, split, or preview mode.\n- Tasks here show up in the vault-wide Tasks view.\n- Links here also show up in Connections.\n\n## Follow-ups\n\n- [ ] Add a sample PDF so the reference-pane flow is demonstrated with a real file.\n- [ ] Add more screenshots for the search palette.\n- [ ] Refine the help text for view-specific ex prompts.\n\n## Notes for tomorrow\n\n- [ ] Carry over open tasks from [[03 — Tables and Task Lists]]\n- [ ] Review [[12 — Settings and Keymaps]] for any missing personalization features\n\n#daily #log #demo\n" - }, - { - path: "inbox/demo/09 — Vim Cheat Sheet.md", - body: "# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading at the cursor |\n| `zo` | Unfold the heading at the cursor |\n| `zM` | Fold all headings |\n| `zR` | Unfold all headings |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 — Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n" - }, - { - path: "inbox/demo/10 — Ideas and Tasks.md", - body: "# Ideas and tasks — a realistic note\n\nThis is the kind of note most real users end up writing: prose, todos, links, snippets, diagrams, and operational context all mixed together. It shows how ZenNotes features compose instead of living in isolated demos.\n\n> [!note]\n> Status as of 2026-04-16. Use this note to test search, outline, connections, Tasks, and split view in one place.\n\n## Open questions\n\n- [ ] Should attachment previews appear inline for PDFs by default?\n- [ ] Is the built-in text-search backend fast enough on large vaults when neither `fzf` nor `ripgrep` is available?\n- [ ] Do we expose tag renaming from the UI, or keep it intentionally file-grep first?\n\n## Working notes\n\n- Quick capture starts in **Quick Notes**, but anything important should graduate into **Inbox**.\n- Cold notes belong in **Archive**, which now opens as a dedicated main-pane list view.\n- Deleted notes should go through **Trash**, where restore and permanent delete are separated on purpose.\n- If tabs are hidden, `Space o` or `:buffers` becomes the fastest way to recover the current working set.\n\n## Now\n\n- [ ] Add a sample PDF + image to the tour so [[06 — Callouts and Footnotes]] can illustrate attachments and reference-pane workflows.\n- [x] Document the Tasks tab behavior in [[03 — Tables and Task Lists]].\n- [ ] Collect feedback on [[09 — Vim Cheat Sheet]] now that keymaps are configurable.\n- [ ] Confirm the search backend badge is visible enough in the vault text search palette.\n\n## Shipped\n\n- [x] Vault text search can use **Auto**, **Built-in**, **ripgrep**, or **fzf**.\n- [x] Custom binary paths can be configured when `rg` or `fzf` live outside `PATH`.\n- [x] Settings now show the resolved runtime backend instead of only the requested one.\n- [x] Archive and Trash both behave as list-style built-in tabs instead of sidebar dump zones.\n\n## Cross-references\n\n- Tour index: [[00 — Start Here]]\n- Search and links: [[07 — Wiki Links and Tags]]\n- Workspace guide: [[11 — Workspace, Search, and Views]]\n- Settings and keymaps: [[12 — Settings and Keymaps]]\n\n## A snippet I keep forgetting\n\nConverting a buffer to hex in Node:\n\n```ts\nimport { randomBytes } from 'node:crypto'\n\nconst buf = randomBytes(16)\nconsole.log(buf.toString('hex'))\n```\n\nConverting back:\n\n```ts\nconst hex = '01020304abcdef'\nconst buf = Buffer.from(hex, 'hex')\n```\n\n## Rough architecture sketch\n\n```mermaid\nflowchart TB\n subgraph Main\n V[Vault I/O]\n W[Watcher]\n T[Task scanner]\n S[Vault text search]\n end\n subgraph Renderer\n E[Editor]\n SB[Sidebar]\n P[Preview]\n O[Outline]\n C[Connections]\n end\n E <-->|IPC| V\n SB -->|IPC| V\n P -->|IPC| V\n O --> E\n C --> E\n V --> T\n V --> S\n W -->|events| V\n```\n\n## A little math\n\nThe rough cost model people keep re-deriving:\n\n$$\nT \\approx 3 \\cdot t \\cdot \\frac{m}{\\text{bandwidth}}\n$$\n\n## Workflow checklist\n\n- [ ] Try this note in **Edit**, **Split**, and **Preview**\n- [ ] Open the **outline** and jump to \"Workflow checklist\"\n- [ ] Open **Connections** and inspect backlinks\n- [ ] Search for `backend` with `Space s t`\n- [ ] Toggle **Zen mode**\n\n#demo #tasks #planning #workspace\n" - }, - { - path: "inbox/demo/11 — Workspace, Search, and Views.md", - body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n" - }, - { - path: "inbox/demo/12 — Settings and Keymaps.md", - body: "# Settings and keymaps\n\nZenNotes is keyboard-first by default, but it is not rigid anymore. Settings now cover both presentation and behavior.\n\n## Appearance\n\nFrom Settings you can tune:\n\n- theme family\n- light or dark mode\n- theme variant or contrast\n- dark sidebar treatment\n\nThe point is to keep the app comfortable for long sessions without changing the underlying note files.\n\n## Editor behavior\n\nKey editor settings include:\n\n- Vim mode on or off\n- leader key hints on or off\n- timed vs sticky leader hints\n- leader hint duration\n- live preview\n- note tabs\n- word wrap\n- PDF behavior in edit mode\n- date-titled Quick Notes\n\n## Vault text search backends\n\nVault text search can be powered by:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\nYou can also set explicit binary paths for `rg` and `fzf` in case they live outside your normal `PATH`.\n\nZenNotes now shows:\n\n- what tools are available\n- what backend is configured\n- what backend is actually being used at runtime\n\nThat matters because **Auto** can fall back, and explicit backends can also fall back when the configured binary path is missing.\n\n## Typography and layout\n\nYou can tune:\n\n- interface font\n- reading font\n- monospace font\n- editor and preview font size\n- line height\n- reading width\n- editor width\n- centered vs left-aligned content\n- line numbers\n\nThese are workflow settings, not note-format settings. The markdown file stays the same.\n\n## Keymaps\n\nKeymaps are now configurable from inside the app:\n\n- global shortcuts\n- leader sequences\n- pane-prefix motions\n- Vim-specific editor actions\n- list and view navigation\n\nThat means you can remap things like:\n\n- search notes\n- search vault text\n- toggle Zen mode\n- pane movement\n- fold motions\n- leader flows such as `Space s t`\n\nMulti-step sequences are supported, so the keymap system can handle more than single shortcuts.\n\n## Vault and About\n\nThe rest of Settings handles the vault and app identity:\n\n- reveal or change the vault location\n- inspect the app version\n- see the About section\n- find the Lumary Labs link\n- remember that Settings save automatically on this device\n\n## Practical advice\n\nIf you are learning the app:\n\n1. keep Vim mode on\n2. enable leader hints\n3. leave search backend on **Auto**\n4. only start remapping after the defaults feel familiar\n\nThat gives you the clearest path through the built-in help, demos, and keyboard flows.\n\nFor a deeper walkthrough of runtime backend selection, fallbacks, and fuzzy content search behavior, see [[15 — Search Backends and Fuzzy Workflows]].\n\n#demo #settings #keymaps #reference\n" - }, - { - path: "inbox/demo/13 — Commands, Help, and Demo Tour.md", - body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo file at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo file\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n" - }, - { - path: "inbox/demo/14 — Reference Pane and Floating Windows.md", - body: "# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local files\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n" - }, - { - path: "inbox/demo/15 — Search Backends and Fuzzy Workflows.md", - body: "# Search backends and fuzzy workflows\n\nZenNotes has two different search surfaces, and the deeper one can be powered by different backends.\n\n## Two searches, two jobs\n\n### Note search\n\nUse when you want to find a note by title or path:\n\n- `⌘P`\n- `Space f`\n\nThis is the fastest way to jump to a file you already roughly know.\n\n### Vault text search\n\nUse when you want to find matching text inside note bodies:\n\n- `Space s t`\n\nThis searches across note content and jumps directly to the matching line when you open a result.\n\n## Backends\n\nVault text search can run on:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\n### Auto\n\n`Auto` prefers:\n\n1. `fzf`\n2. `ripgrep`\n3. built-in fallback\n\nThat makes the app adapt to what is installed on the machine.\n\n### Built-in\n\nUse this when you want:\n\n- zero external dependencies\n- predictable behavior across machines\n- a search path that always exists even when no tools are installed\n\n### ripgrep\n\nUse this when you want:\n\n- strong plain-text search performance\n- system-level tooling you may already use outside the app\n- a backend that is familiar to terminal users\n\n### fzf\n\nUse this when you want:\n\n- terminal-style fuzzy matching behavior\n- ranking that feels close to launcher workflows\n- an external backend often used by Vim and Neovim users\n\n## Custom binary paths\n\nIf `rg` or `fzf` are not in your normal `PATH`, ZenNotes lets you point to them directly from Settings.\n\nExamples:\n\n- `/opt/homebrew/bin/rg`\n- `/opt/homebrew/bin/fzf`\n- `/usr/local/bin/rg`\n\nBlank means “use whatever is on PATH”.\n\n## Runtime backend vs configured backend\n\nZenNotes shows:\n\n- what you configured\n- what tools are available\n- what backend is actually being used\n\nThat distinction matters because:\n\n- `Auto` may resolve differently on different machines\n- explicit `ripgrep` or `fzf` settings can still fall back if the binary path is invalid\n\n## Search result behavior\n\nVault text search is designed to be navigational, not just informational:\n\n- results stay keyboard navigable\n- the active row stays in view while you move\n- the matching text is highlighted in the result\n- opening a result moves the cursor to the match in the note\n\nThis makes it feel more like a picker than a grep dump.\n\n## Good habits\n\n- use note search when you know the file\n- use vault text search when you only know the phrase\n- leave the backend on **Auto** unless you have a reason to force one\n- configure explicit binary paths if your tools live outside `PATH`\n\n## Related notes\n\n- [[07 — Wiki Links and Tags]] for search in the context of notes, tags, and links\n- [[11 — Workspace, Search, and Views]] for where these pickers fit into the app\n- [[12 — Settings and Keymaps]] for changing the backend and remapping the shortcut\n\n#demo #search #fzf #ripgrep #reference\n" - }, -] - -export const DEMO_TOUR_ASSETS: DemoTourTemplateFile[] = [ - { - path: "zennotes-demo-card.svg", - body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n DEMO\n ZenNotes Demo\n Local files, keyboard-first flows, and markdown-friendly structure.\n \n \n \n \n \n \n \n SEE ALSO: HELP, SEARCH, OUTLINE, TASKS, QUICK NOTES\n\n" - }, -] \ No newline at end of file +export { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from '@zennotes/shared-domain/demo-tour-data' +export type { DemoTourTemplateFile } from '@zennotes/shared-domain/demo-tour-data' diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d05fa99c..d9f9bd65 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -226,6 +226,7 @@ import { import { getCliInstallStatus, installCli, + migrateInstalledCli, migrateLegacyCliLink, uninstallCli, } from "./cli-install"; @@ -4553,7 +4554,9 @@ function registerIpc(): void { ); handle(IPC.CLI_GET_STATUS, async () => await getCliInstallStatus()); - handle(IPC.CLI_INSTALL, async () => await installCli()); + handle(IPC.CLI_INSTALL, async (_event, request: unknown) => + await installCli(request), + ); handle(IPC.CLI_UNINSTALL, async () => await uninstallCli()); handle(IPC.RAYCAST_GET_STATUS, async () => await getRaycastExtensionStatus()); handle(IPC.RAYCAST_INSTALL, async () => await installRaycastExtension()); @@ -5248,16 +5251,16 @@ app.whenReady().then(async () => { await migrateLegacyRemoteWorkspaceSecrets(); - // Fire-and-forget: heals the pre-2.10 `zen` symlink into `zn` for users who - // never re-ran the CLI installer. Must not delay or fail startup — a broken - // PATH probe is a log line, not a launch problem. (#126) + // Heal the legacy command name and upgrade existing desktop-owned shortcuts. + // A PATH or runtime staging failure must not delay or fail app startup. void migrateLegacyCliLink() + .then(() => migrateInstalledCli()) .then((linkPath) => { if (linkPath) - console.log(`[cli] migrated legacy zen symlink to ${linkPath}`); + console.log(`[cli] updated managed CLI shortcut at ${linkPath}`); }) .catch((err) => - console.warn("[cli] legacy symlink migration failed:", err), + console.warn("[cli] managed CLI migration failed:", err), ); protocol.handle(LOCAL_ASSET_SCHEME, async (request) => { diff --git a/apps/desktop/src/main/note-creation-metadata.ts b/apps/desktop/src/main/note-creation-metadata.ts new file mode 100644 index 00000000..f7ec1c16 --- /dev/null +++ b/apps/desktop/src/main/note-creation-metadata.ts @@ -0,0 +1,175 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { randomInt } from 'node:crypto' + +const metadataDirectory = '.zennotes/note-metadata' +const metadataSuffix = '.metadata.json' + +export async function noteMetadataPath( + root: string, + rel: string, + directory = false, +): Promise { + const base = path.resolve(root, metadataDirectory) + const target = path.resolve(base, rel + (directory ? '' : metadataSuffix)) + if (target === base || !target.startsWith(base + path.sep)) + throw new Error('Path escapes note metadata') + // Metadata is app-owned. A sidecar link must never redirect a save outside + // the vault, even when the Markdown itself is intentionally symlinked. + let current = path.resolve(root) + for (const part of path.relative(current, target).split(path.sep)) { + current = path.join(current, part) + try { + if ((await fs.lstat(current)).isSymbolicLink()) + throw new Error('Symlinked note metadata is not supported') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') break + throw error + } + } + return target +} + +async function readCreationMetadata(abs: string): Promise { + let raw: string + try { + raw = await fs.readFile(abs, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } + let value: { version?: unknown; createdAt?: unknown } + try { + value = JSON.parse(raw) + } catch { + throw new Error(`Invalid note creation metadata: ${abs}`) + } + if ( + raw.length > 4096 || + value?.version !== 1 || + typeof value.createdAt !== 'number' || + !Number.isSafeInteger(value.createdAt) || + value.createdAt <= 0 || + value.createdAt > 8640000000000000 + ) + throw new Error(`Invalid note creation metadata: ${abs}`) + return value.createdAt +} + +export async function readNoteCreatedAt( + root: string, + rel: string, + fallback: number, +): Promise { + try { + return ( + (await readCreationMetadata(await noteMetadataPath(root, rel))) ?? + fallback + ) + } catch { + // Corrupt optional metadata must not hide the user's Markdown. Saves are + // stricter, so they cannot silently discard a date that needs repair. + return fallback + } +} + +export async function prepareNoteCreation( + root: string, + rel: string, +): Promise { + if ( + rel.replaceAll('\\', '/').startsWith('.zennotes/') || + !/\.(md|excalidraw)$/i.test(rel) + ) + return + const abs = path.resolve(root, rel) + if (!abs.startsWith(path.resolve(root) + path.sep)) + throw new Error('Path escapes vault') + const metadata = await noteMetadataPath(root, rel) + // Stat before reading metadata: a concurrent writer publishes the original + // date before replacing the inode, so observing the new inode is safe. + const previous = await fs.stat(abs).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null + throw error + }) + if (!previous) { + await fs.rm(metadata, { force: true }) + return + } + if ((await readCreationMetadata(metadata)) !== null) return + const createdAt = Math.trunc(previous.birthtimeMs || previous.ctimeMs) + await fs.mkdir(path.dirname(metadata), { recursive: true }) + const temporary = `${metadata}.${process.pid}.${Date.now()}${String(randomInt(1000000)).padStart(6, '0')}.tmp` + try { + const file = await fs.open(temporary, 'wx', previous.mode & 0o777) + try { + await file.writeFile(JSON.stringify({ version: 1, createdAt }) + '\n') + await file.sync() + } finally { + await file.close() + } + await fs.rename(temporary, metadata) + } finally { + await fs.rm(temporary, { force: true }) + } +} + +export async function removeNoteCreation( + root: string, + rel: string, + directory = false, +): Promise { + await fs.rm(await noteMetadataPath(root, rel, directory), { + force: true, + recursive: directory, + }) +} + +export async function moveWithCreationMetadata( + root: string, + from: string, + to: string, + directory = false, +): Promise { + if (from === to) return + const source = await noteMetadataPath( + root, + path.relative(root, from), + directory, + ) + const target = await noteMetadataPath( + root, + path.relative(root, to), + directory, + ) + const exists = async (abs: string): Promise => + fs.lstat(abs).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return false + throw error + }, + ) + const hasMetadata = await exists(source) + if (await exists(target)) + throw new Error(`Destination metadata already exists: ${target}`) + if (hasMetadata) { + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.rename(source, target) + } + try { + await fs.rename(from, to) + } catch (error) { + if (hasMetadata) { + try { + await fs.rename(target, source) + } catch (rollback) { + throw new AggregateError( + [error, rollback], + 'Note move and metadata rollback failed; reload before editing', + ) + } + } + throw error + } +} diff --git a/apps/desktop/src/main/terminal-runtime.test.ts b/apps/desktop/src/main/terminal-runtime.test.ts new file mode 100644 index 00000000..75773ff8 --- /dev/null +++ b/apps/desktop/src/main/terminal-runtime.test.ts @@ -0,0 +1,109 @@ +import { + mkdtemp, + mkdir, + writeFile, + readFile, + readlink, + rm, +} from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import path from 'node:path' +import os from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { prepareTerminalRuntime } from './terminal-runtime' +const exec = promisify(execFile) +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) + await rm(root, { recursive: true, force: true }) +}) +async function fixture(version = '1.0.0') { + const root = await mkdtemp(path.join(os.tmpdir(), "zn terminal ' ")) + roots.push(root) + const bundleDir = path.join(root, 'resources', 'terminal') + await mkdir(bundleDir, { recursive: true }) + const binary = `#!/bin/sh\nif [ "$1" = --desktop-integration ]; then\n echo '{"protocol":1,"version":"${version}"}'\n exit\nfi\nprintf '%s\\n' "$ZENNOTES_WORKSPACE_SOURCE" "$@"\nexit 7\n` + await writeFile(path.join(bundleDir, 'zn'), binary, { mode: 0o755 }) + await writeFile( + path.join(bundleDir, 'manifest.json'), + JSON.stringify({ + schemaVersion: 1, + protocol: 1, + version, + platform: process.platform, + arch: process.arch, + }), + ) + const legacy = path.join(root, 'legacy') + await writeFile(legacy, '#!/bin/sh\nprintf "legacy:%s\\n" "$@"\n', { + mode: 0o755, + }) + return { + root, + bundleDir, + userData: path.join(root, 'user data'), + platform: process.platform, + arch: process.arch, + legacyCommand: [legacy], + } +} +describe.skipIf(process.platform === 'win32')( + 'persistent terminal runtime', + () => { + it('survives bundle removal and preserves arguments, workspace context, exit code, and explicit rollback', async () => { + const options = await fixture() + const runtime = await prepareTerminalRuntime(options) + expect(runtime?.version).toBe('1.0.0') + await rm(options.bundleDir, { recursive: true }) + const result = await exec(runtime!.launcherPath, ['read', 'a b.md'], { + env: { ...process.env, ZENNOTES_WORKSPACE_SOURCE: '' }, + }).catch((e) => e) + expect(result.code).toBe(7) + expect(result.stdout).toBe('app\nread\na b.md\n') + const rollback = await exec(runtime!.launcherPath, ['read', 'a b.md'], { + env: { ...process.env, ZENNOTES_CLI_ENGINE: 'legacy' }, + }) + expect(rollback.stdout).toBe('legacy:read\nlegacy:a b.md\n') + }) + it('retains the active version if the replacement fails its integration probe', async () => { + const options = await fixture() + const first = await prepareTerminalRuntime(options) + const current = await readlink( + path.join(options.userData, 'cli', 'terminal', 'current'), + ) + await writeFile( + path.join(options.bundleDir, 'zn'), + '#!/bin/sh\necho broken\n', + { mode: 0o755 }, + ) + await expect(prepareTerminalRuntime(options)).rejects.toThrow( + /integration/i, + ) + expect( + await readlink( + path.join(options.userData, 'cli', 'terminal', 'current'), + ), + ).toBe(current) + expect( + createHash('sha256') + .update(await readFile(first!.binaryPath)) + .digest('hex'), + ).toBe(first!.sha256) + }) + it('declines absent artifacts and refuses mismatched platforms or foreign launchers', async () => { + const options = await fixture() + await expect( + prepareTerminalRuntime({ ...options, arch: 'unsupported' }), + ).rejects.toThrow(/platform|architecture/i) + await mkdir(path.join(options.userData, 'cli'), { recursive: true }) + const launcher = path.join(options.userData, 'cli', 'zn') + await writeFile(launcher, 'owned by someone else') + await expect(prepareTerminalRuntime(options)).rejects.toThrow(/managed/i) + expect(await readFile(launcher, 'utf8')).toBe('owned by someone else') + await rm(options.bundleDir, { recursive: true }) + expect(await prepareTerminalRuntime(options)).toBeNull() + }) + }, +) diff --git a/apps/desktop/src/main/terminal-runtime.ts b/apps/desktop/src/main/terminal-runtime.ts new file mode 100644 index 00000000..e67b0143 --- /dev/null +++ b/apps/desktop/src/main/terminal-runtime.ts @@ -0,0 +1,265 @@ +import { constants, promises as fs } from 'node:fs' +import type { FileHandle } from 'node:fs/promises' +import { createHash, randomUUID } from 'node:crypto' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import path from 'node:path' + +const exec = promisify(execFile) +const LAUNCHER_MARKER = '# ZenNotes managed terminal launcher v1' +export interface TerminalRuntime { + launcherPath: string + binaryPath: string + version: string + sha256: string +} +export interface TerminalRuntimeOptions { + bundleDir: string + userData: string + platform: string + arch: string + legacyCommand: string[] + appPath?: string +} +interface Manifest { + schemaVersion: number + protocol: number + version: string + platform: string + arch: string +} +const pending = new Map>() +const digest = (data: Buffer): string => + createHash('sha256').update(data).digest('hex') +const quote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'` + +async function atomicWrite( + target: string, + content: string, + mode = 0o600, +): Promise { + const temporary = `${target}.${randomUUID()}.tmp` + try { + await fs.writeFile(temporary, content, { mode, flag: 'wx' }) + await fs.rename(temporary, target) + } finally { + await fs.rm(temporary, { force: true }) + } +} + +function launcher(options: TerminalRuntimeOptions, current: string): string { + return [ + '#!/bin/sh', + LAUNCHER_MARKER, + 'case "${ZENNOTES_CLI_ENGINE:-go}" in', + ` legacy) ELECTRON_RUN_AS_NODE=1 exec ${options.legacyCommand.map(quote).join(' ')} "$@" ;;`, + ' go) ;;', + ' *) echo "zn: ZENNOTES_CLI_ENGINE must be go or legacy." >&2; exit 2 ;;', + 'esac', + ': "${ZENNOTES_WORKSPACE_SOURCE:=app}"', + 'export ZENNOTES_WORKSPACE_SOURCE', + ...(options.appPath + ? [ + `if [ -z "\${ZENNOTES_APP_PATH:-}" ]; then ZENNOTES_APP_PATH=${quote(options.appPath)}; export ZENNOTES_APP_PATH; fi`, + ] + : []), + `exec ${quote(path.join(current, 'zn'))} "$@"`, + '', + ].join('\n') +} + +/** A failed stage never replaces the active runtime or retries a command. */ +export function prepareTerminalRuntime( + options: TerminalRuntimeOptions, +): Promise { + const key = `${options.bundleDir}\0${options.userData}` + const existing = pending.get(key) + if (existing) return existing + const operation = prepare(options).finally(() => { + pending.delete(key) + }) + pending.set(key, operation) + return operation +} + +async function prepare( + options: TerminalRuntimeOptions, +): Promise { + let manifest: Manifest + try { + manifest = JSON.parse( + await fs.readFile(path.join(options.bundleDir, 'manifest.json'), 'utf8'), + ) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw new Error('The bundled terminal manifest is invalid.', { + cause: error, + }) + } + if ( + manifest.schemaVersion !== 1 || + manifest.protocol !== 1 || + typeof manifest.version !== 'string' || + !/^[a-zA-Z0-9][a-zA-Z0-9.+-]{0,99}$/.test(manifest.version) + ) { + throw new Error('The bundled terminal integration manifest is unsupported.') + } + if ( + manifest.platform !== options.platform || + manifest.arch !== options.arch || + !['darwin', 'linux'].includes(options.platform) || + !['x64', 'arm64'].includes(options.arch) + ) { + throw new Error( + 'The bundled terminal platform or architecture does not match this app.', + ) + } + const base = path.join(options.userData, 'cli') + const runtimeRoot = path.join(base, 'terminal') + const versions = path.join(runtimeRoot, 'versions') + const current = path.join(runtimeRoot, 'current') + const launcherPath = path.join(base, 'zn') + await fs.mkdir(versions, { recursive: true, mode: 0o700 }) + await readManagedLauncher(launcherPath) + try { + if (!(await fs.lstat(current)).isSymbolicLink()) + throw new Error('The active terminal path is not a managed link.') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + + // Signing can change upstream bytes. Compare with this bundle's executable; + // the release archive checksum is verified before packaging. + const bytes = await fs.readFile(path.join(options.bundleDir, 'zn')) + const sha256 = digest(bytes) + try { + const installed = JSON.parse( + await fs.readFile(path.join(current, 'installed.json'), 'utf8'), + ) + if ( + installed.sha256 === sha256 && + installed.version === manifest.version && + digest(await fs.readFile(path.join(current, 'zn'))) === sha256 && + ((await fs.stat(path.join(current, 'zn'))).mode & 0o111) !== 0 + ) { + await atomicWrite(launcherPath, launcher(options, current), 0o755) + return { + launcherPath, + binaryPath: path.join(current, 'zn'), + version: manifest.version, + sha256, + } + } + } catch { + /* Missing or damaged copy is replaced through a fresh stage. */ + } + + const stage = await fs.mkdtemp(path.join(versions, `${manifest.version}-`)) + const binaryPath = path.join(stage, 'zn') + let activated = false + const next = path.join(runtimeRoot, `current.${randomUUID()}.tmp`) + try { + await fs.writeFile(binaryPath, bytes, { mode: 0o755, flag: 'wx' }) + if (digest(await fs.readFile(binaryPath)) !== sha256) + throw new Error('Terminal copy verification failed.') + let integration: { protocol?: number; version?: string } + try { + const result = await exec(binaryPath, ['--desktop-integration'], { + timeout: 10000, + maxBuffer: 65536, + }) + integration = JSON.parse(result.stdout) + } catch (error) { + throw new Error('Terminal integration probe failed.', { cause: error }) + } + if ( + integration.protocol !== 1 || + integration.version !== manifest.version + ) { + throw new Error( + 'Terminal integration version does not match the bundled manifest.', + ) + } + await atomicWrite( + path.join(stage, 'installed.json'), + JSON.stringify({ ...manifest, sha256 }) + '\n', + ) + await atomicWrite(launcherPath, launcher(options, current), 0o755) + await fs.symlink(path.relative(runtimeRoot, stage), next) + await fs.rename(next, current) + activated = true + return { launcherPath, binaryPath, version: manifest.version, sha256 } + } finally { + await fs.rm(next, { force: true }) + if (!activated) await fs.rm(stage, { recursive: true, force: true }) + } +} + +/** + * Reads a launcher this app may replace. One handle, opened without following + * links, serves both the type check and the content check, so nothing can be + * swapped in between: the result is a regular file carrying our marker, or + * null when there is no launcher at all. Anything else is refused. + */ +async function readManagedLauncher(launcherPath: string): Promise { + const refuse = () => + new Error(`${launcherPath} is not a managed ZenNotes launcher.`) + let handle: FileHandle + try { + handle = await fs.open(launcherPath, constants.O_RDONLY | constants.O_NOFOLLOW) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return null + if (code === 'ELOOP') throw refuse() + throw error + } + try { + if (!(await handle.stat()).isFile()) throw refuse() + const content = await handle.readFile('utf8') + if (!content.startsWith(`#!/bin/sh\n${LAUNCHER_MARKER}\n`)) throw refuse() + return content + } finally { + await handle.close() + } +} + +/** Retain a verified installed version when a new bundle cannot be activated. */ +export async function readActiveTerminalRuntime( + userData: string, +): Promise { + const launcherPath = path.join(userData, 'cli', 'zn') + const current = path.join(userData, 'cli', 'terminal', 'current') + const binaryPath = path.join(current, 'zn') + try { + const installed = JSON.parse( + await fs.readFile(path.join(current, 'installed.json'), 'utf8'), + ) + // The mode check and the digest come from one open handle, so the bytes + // that are hashed are the bytes whose mode was checked. + const binary = await fs.open(binaryPath, 'r') + let executable = false + let bytes: Buffer + try { + executable = Boolean((await binary.stat()).mode & 0o111) + bytes = await binary.readFile() + } finally { + await binary.close() + } + if ( + installed.protocol !== 1 || + typeof installed.version !== 'string' || + !executable || + digest(bytes) !== installed.sha256 || + (await readManagedLauncher(launcherPath)) === null + ) + return null + return { + launcherPath, + binaryPath, + version: installed.version, + sha256: installed.sha256, + } + } catch { + return null + } +} diff --git a/apps/desktop/src/main/updater-linux-install.test.ts b/apps/desktop/src/main/updater-linux-install.test.ts new file mode 100644 index 00000000..36efa098 --- /dev/null +++ b/apps/desktop/src/main/updater-linux-install.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const boundary = vi.hoisted(() => ({ + execFile: vi.fn(), + quit: vi.fn(), + relaunch: vi.fn(), + reveal: vi.fn(), + broadcast: vi.fn(), + updater: null as any +})) + +vi.mock('electron', () => ({ + app: { + isPackaged: true, + getVersion: () => '2.50.3', + quit: boundary.quit, + relaunch: boundary.relaunch + }, + BrowserWindow: { + getAllWindows: () => [{ webContents: { send: boundary.broadcast } }] + }, + Notification: { isSupported: () => false }, + shell: { showItemInFolder: boundary.reveal } +})) + +vi.mock('node:child_process', () => ({ execFile: boundary.execFile })) + +vi.mock('node:fs', async (importOriginal) => { + const real = await importOriginal() + return { + ...real, + existsSync: (file: string) => + file === '/opt/ZenNotes/resources/package-type' || real.existsSync(file), + readFileSync: (file: string, ...args: any[]) => + file === '/etc/os-release' + ? 'ID=arch\n' + : (real.readFileSync as any)(file, ...args) + } +}) + +vi.mock('electron-updater', async () => { + const { EventEmitter } = await import('node:events') + class FixtureUpdater extends EventEmitter { + autoDownload = true + autoInstallOnAppQuit = true + quitAndInstall = vi.fn() + checkForUpdates = vi.fn(async () => { + this.emit('update-available', { version: '2.50.4' }) + }) + downloadUpdate = vi.fn(async () => { + this.emit('update-downloaded', { + version: '2.50.4', + downloadedFile: '/home/test/.cache/@zennotesdesktop-updater/pending/ZenNotes-2.50.4-linux-x64.pacman' + }) + }) + constructor() { + super() + boundary.updater = this + } + } + return { + default: { + autoUpdater: new FixtureUpdater(), + AppImageUpdater: FixtureUpdater, + DebUpdater: FixtureUpdater, + RpmUpdater: FixtureUpdater, + PacmanUpdater: FixtureUpdater + } + } +}) + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! +const originalResources = Object.getOwnPropertyDescriptor(process, 'resourcesPath') +const downloadedPackage = + '/home/test/.cache/@zennotesdesktop-updater/pending/ZenNotes-2.50.4-linux-x64.pacman' + +type ExecCallback = (error: Error | null, stdout?: string, stderr?: string) => void + +function execCallback(args: unknown[]): ExecCallback { + return args[args.length - 1] as ExecCallback +} + +function failure(code: string | number, message: string, stderr = ''): Error { + return Object.assign(new Error(message), { code, stderr }) +} + +async function readyUpdate() { + const updater = await import('./updater') + expect((await updater.checkForAppUpdates()).phase).toBe('available') + expect((await updater.downloadAppUpdate()).phase).toBe('downloaded') + expect(boundary.updater.autoInstallOnAppQuit).toBe(false) + return updater +} + +async function flushInstall(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: '/opt/ZenNotes/resources' + }) + vi.stubEnv('APPIMAGE', '') + vi.stubEnv('ZENNOTES_UPDATER_FORMAT', '') +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + Object.defineProperty(process, 'platform', originalPlatform) + if (originalResources) Object.defineProperty(process, 'resourcesPath', originalResources) + else Reflect.deleteProperty(process, 'resourcesPath') +}) + +// The suite fakes a Linux platform, but its download and pkexec doubles are +// POSIX shell scripts, so it only runs where those can execute. +describe.skipIf(process.platform === 'win32')('downloaded .pacman update installation', () => { + it('does not leave a hidden terminal authentication prompt waiting in a GUI session', async () => { + boundary.execFile.mockImplementation((_file: string, args: string[], ...rest: unknown[]) => { + // pkexec can fall back to a textual agent on the controlling terminal. + // An app launched from a window-manager binding cannot present it in its UI. + if (args.includes('--disable-internal-agent')) { + queueMicrotask(() => execCallback(rest)(failure(127, 'No authentication agent found'))) + } + }) + const updater = await readyUpdate() + updater.installAppUpdate() + await flushInstall() + + expect(updater.getAppUpdateState().phase).toBe('error') + expect(updater.getAppUpdateState().message).toContain('sudo pacman -U') + expect(boundary.relaunch).not.toHaveBeenCalled() + expect(boundary.quit).not.toHaveBeenCalled() + }) + + it('reports failed authorization with a manual installation route instead of saying the user canceled', async () => { + boundary.execFile.mockImplementation((...args: unknown[]) => { + queueMicrotask(() => execCallback(args)(failure(127, 'Not authorized', 'No authentication agent found.'))) + }) + const updater = await readyUpdate() + updater.installAppUpdate() + await flushInstall() + + expect(updater.getAppUpdateState().phase).toBe('error') + expect(updater.getAppUpdateState().message).toContain('sudo pacman -U') + expect(updater.getAppUpdateState().message).not.toMatch(/canceled/i) + expect(boundary.quit).not.toHaveBeenCalled() + }) + + it('allows only one administrator installation while its prompt is pending', async () => { + const pending: ExecCallback[] = [] + boundary.execFile.mockImplementation((...args: unknown[]) => { + pending.push(execCallback(args)) + }) + const updater = await readyUpdate() + updater.installAppUpdate() + updater.installAppUpdate() + + expect(updater.getAppUpdateState().phase).toBe('installing') + expect((await updater.checkForAppUpdates()).phase).toBe('installing') + expect(pending).toHaveLength(1) + expect(boundary.quit).not.toHaveBeenCalled() + pending[0](failure(126, 'Dismissed')) + await flushInstall() + }) + + it('keeps a dismissed administrator prompt retryable without quitting', async () => { + boundary.execFile.mockImplementation((...args: unknown[]) => { + queueMicrotask(() => execCallback(args)(failure(126, 'Dismissed'))) + }) + const updater = await readyUpdate() + updater.installAppUpdate() + await flushInstall() + + expect(updater.getAppUpdateState().phase).toBe('downloaded') + expect(updater.getAppUpdateState().message).toMatch(/canceled|cancelled/i) + expect(boundary.quit).not.toHaveBeenCalled() + expect(boundary.updater.quitAndInstall).not.toHaveBeenCalled() + }) + + it('shows the retained package and install command when pkexec is unavailable', async () => { + boundary.execFile.mockImplementation((...args: unknown[]) => { + queueMicrotask(() => execCallback(args)(failure('ENOENT', 'spawn pkexec ENOENT'))) + }) + const updater = await readyUpdate() + updater.installAppUpdate() + await flushInstall() + + expect(updater.getAppUpdateState()).toMatchObject({ phase: 'error' }) + expect(updater.getAppUpdateState().message).toContain(downloadedPackage) + expect(updater.getAppUpdateState().message).toContain('sudo pacman -U') + expect(boundary.reveal).toHaveBeenCalledWith(downloadedPackage) + expect(boundary.quit).not.toHaveBeenCalled() + }) + + it('preserves install failure guidance when the startup background check becomes due', async () => { + vi.useFakeTimers() + boundary.execFile.mockImplementation((...args: unknown[]) => { + queueMicrotask(() => execCallback(args)(failure(127, 'No authentication agent found'))) + }) + const updater = await import('./updater') + updater.scheduleBackgroundAppUpdateCheck(8_000) + await readyUpdate() + updater.installAppUpdate() + await vi.advanceTimersByTimeAsync(1) + const failureState = updater.getAppUpdateState() + expect(failureState.phase).toBe('error') + await vi.advanceTimersByTimeAsync(8_000) + expect(updater.getAppUpdateState()).toEqual(failureState) + expect(boundary.updater.checkForUpdates).toHaveBeenCalledOnce() + }) + + it('keeps the app open and explains package-manager failure', async () => { + boundary.execFile.mockImplementation((...args: unknown[]) => { + queueMicrotask(() => execCallback(args)(failure(1, 'pacman: failed to commit transaction'))) + }) + const updater = await readyUpdate() + updater.installAppUpdate() + await flushInstall() + + expect(updater.getAppUpdateState().phase).toBe('error') + expect(updater.getAppUpdateState().message).toMatch(/failed to commit transaction/) + expect(boundary.quit).not.toHaveBeenCalled() + }) + + it('relaunches only after the system package has been installed successfully', async () => { + let complete!: ExecCallback + boundary.execFile.mockImplementation((...args: unknown[]) => { + complete = execCallback(args) + }) + const updater = await readyUpdate() + updater.installAppUpdate() + expect(boundary.quit).not.toHaveBeenCalled() + complete(null, '', '') + await flushInstall() + + expect(boundary.relaunch).toHaveBeenCalledOnce() + expect(boundary.quit).toHaveBeenCalledOnce() + expect(boundary.updater.quitAndInstall).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 5f6c7ea9..c44d0334 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -282,7 +282,7 @@ export async function checkForAppUpdates(): Promise { initAppUpdater() if (managedInstall) return await checkManagedInstallForUpdates() if (!updater) return getAppUpdateState() - if (updateState.phase === 'checking') return getAppUpdateState() + if (updateState.phase === 'checking' || updateState.phase === 'installing') return getAppUpdateState() setUpdateState( nextStateFromInfo('checking', lastInfo, 'Checking GitHub releases for updates…') @@ -394,7 +394,8 @@ export function scheduleBackgroundAppUpdateCheck( backgroundCheckScheduled = true startupCheckTimer = setTimeout(() => { startupCheckTimer = null - void checkForAppUpdates() + // A manual check/download/install owns its result once the user starts it. + if (updateState.phase === 'idle') void checkForAppUpdates() }, Math.max(0, delayMs)) } @@ -715,15 +716,15 @@ async function installLinuxPackageUpdate(file: string): Promise { setUpdateState( nextStateFromInfo( - 'downloaded', + 'installing', lastInfo, `Installing ZenNotes ${lastInfo?.version ?? ''}… approve the administrator prompt to finish.` ) ) try { - // pkexec shows a graphical password prompt and runs the install as root. - await execFileAsync('pkexec', ['sh', '-c', script]) + // Require a graphical agent; a hidden terminal prompt cannot be answered here. + await execFileAsync('pkexec', ['--disable-internal-agent', 'sh', '-c', script]) } catch (error) { handleLinuxInstallFailure(format, file, error) return @@ -749,15 +750,14 @@ function handleLinuxInstallFailure( nextStateFromInfo( 'error', lastInfo, - `Couldn't install automatically: pkexec (graphical sudo) isn't available on this system. The update was downloaded to ${file} — install it manually with: ${hint}, then reopen ZenNotes.` + `Couldn't install automatically: pkexec (graphical sudo) isn't available on this system. The update was downloaded to ${file}; install it manually with: ${hint}, then reopen ZenNotes.` ) ) return } - // pkexec exits 126/127 when the auth dialog is dismissed or not authorized. - // Keep the update ready so the user can retry. - if (code === 126 || code === 127) { + // Only 126 means the user dismissed the authorization dialog. + if (code === 126) { setUpdateState( nextStateFromInfo( 'downloaded', @@ -768,6 +768,15 @@ function handleLinuxInstallFailure( return } + if (code === 127) { + revealDownloadedPackage(file) + setUpdateState(nextStateFromInfo( + 'error', lastInfo, + `Administrator authorization failed. Make sure a graphical polkit agent is running, or install the downloaded package with: ${hint}, then reopen ZenNotes.` + )) + return + } + // dpkg/apt (or rpm/pacman) failed. revealDownloadedPackage(file) const detail = error instanceof Error ? error.message.trim() : String(error) @@ -775,7 +784,7 @@ function handleLinuxInstallFailure( nextStateFromInfo( 'error', lastInfo, - `Update install failed: ${detail || 'unknown error'}. The package is at ${file} — you can install it manually with: ${hint}.` + `Update install failed: ${detail || 'unknown error'}. The package is at ${file}; you can install it manually with: ${hint}.` ) ) } diff --git a/apps/desktop/src/main/vault-creation-metadata.test.ts b/apps/desktop/src/main/vault-creation-metadata.test.ts new file mode 100644 index 00000000..9c924327 --- /dev/null +++ b/apps/desktop/src/main/vault-creation-metadata.test.ts @@ -0,0 +1,342 @@ +import { mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const environment = vi.hoisted(() => ({ userData: '' })) +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => { + if (!environment.userData) throw new Error('Vault test has not initialized app data') + return environment.userData + } + } +})) + +import { + createNote, + deleteNote, + getVaultSettings, + invalidateNoteMetaCache, + invalidateVaultSettingsCache, + listNotes, + moveToTrash, + readNote, + renameFolder, + renameNote, + restoreFromTrash, + setVaultSettings, + writeNote +} from './vault' +import * as mcpVault from '../mcp/vault-ops' + +const roots: string[] = [] +const ORIGINAL_CREATED_AT = Date.UTC(2001, 1, 3, 4, 5, 6, 123) +const ORIGINAL_BODY = '# Café 笔记\r\n\r\nKeep **Markdown** and trailing spaces. \r\n- [ ] Exact bytes\r\n' + +function metadataPath(root: string, notePath: string): string { + return path.join(root, '.zennotes', 'note-metadata', `${notePath}.metadata.json`) +} + +async function makeVault(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'zn-creation-metadata-')) + roots.push(root) + environment.userData = path.join(root, '.test-app') + await mkdir(environment.userData, { recursive: true }) + vi.stubEnv('ZENNOTES_USER_DATA_PATH', environment.userData) + vi.stubEnv('ZENNOTES_CONFIG_DIR', environment.userData) + return root +} + +async function seedNote( + root: string, + notePath: string, + createdAt = ORIGINAL_CREATED_AT, + body = ORIGINAL_BODY +): Promise { + const note = path.join(root, notePath) + const metadata = metadataPath(root, notePath) + await mkdir(path.dirname(note), { recursive: true }) + await mkdir(path.dirname(metadata), { recursive: true }) + await writeFile(note, body) + await writeFile(metadata, JSON.stringify({ version: 1, createdAt })) +} + +async function readMetadata(root: string, notePath: string): Promise { + return JSON.parse(await readFile(metadataPath(root, notePath), 'utf8')) +} + +afterEach(async () => { + for (const root of roots.splice(0)) { + invalidateNoteMetaCache(root) + invalidateVaultSettingsCache(root) + await rm(root, { recursive: true, force: true }) + } + environment.userData = '' + vi.unstubAllEnvs() +}) + +describe('portable note creation metadata across desktop vault operations', () => { + it('reads the portable creation date in both the editor and note list', async () => { + const root = await makeVault() + const notePath = 'inbox/Original.md' + await seedNote(root, notePath) + + const loaded = await readNote(root, notePath) + const listed = (await listNotes(root)).find(note => note.path === notePath) + + expect(loaded.createdAt).toBe(ORIGINAL_CREATED_AT) + expect(listed?.createdAt).toBe(ORIGINAL_CREATED_AT) + expect(loaded.body).toBe(ORIGINAL_BODY) + expect(await readFile(path.join(root, notePath))).toEqual(Buffer.from(ORIGINAL_BODY)) + }) + + it('preserves the creation date and exact Markdown bytes through an atomic save', async () => { + const root = await makeVault() + const notePath = 'inbox/Original.md' + await seedNote(root, notePath) + const editedBody = '# Café 笔记\r\n\r\nRevised **Markdown**. \r\n\tIndented 😀\r\n' + + const saved = await writeNote(root, notePath, editedBody) + const loaded = await readNote(root, notePath) + + expect(saved.createdAt).toBe(ORIGINAL_CREATED_AT) + expect(loaded.createdAt).toBe(ORIGINAL_CREATED_AT) + expect(loaded.body).toBe(editedBody) + expect(await readFile(path.join(root, notePath))).toEqual(Buffer.from(editedBody)) + expect(await readMetadata(root, notePath)).toEqual({ version: 1, createdAt: ORIGINAL_CREATED_AT }) + }) + + it('moves the creation metadata with a renamed note', async () => { + const root = await makeVault() + const oldPath = 'inbox/Original.md' + const newPath = 'inbox/Renamed café.md' + await seedNote(root, oldPath) + + const renamed = await renameNote(root, oldPath, 'Renamed café') + + expect(renamed.path).toBe(newPath) + expect(renamed.createdAt).toBe(ORIGINAL_CREATED_AT) + expect((await readNote(root, newPath)).createdAt).toBe(ORIGINAL_CREATED_AT) + expect(await readFile(path.join(root, newPath))).toEqual(Buffer.from(ORIGINAL_BODY)) + expect(await readMetadata(root, newPath)).toEqual({ version: 1, createdAt: ORIGINAL_CREATED_AT }) + await expect(readFile(metadataPath(root, oldPath))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('moves every nested creation date when its folder is renamed', async () => { + const root = await makeVault() + const first = 'inbox/Research/Original.md' + const nested = 'inbox/Research/Subfolder/Second.md' + const neighbour = 'inbox/Unrelated.md' + await seedNote(root, first) + await seedNote(root, nested, ORIGINAL_CREATED_AT + 1000) + await seedNote(root, neighbour, ORIGINAL_CREATED_AT + 2000) + + await renameFolder(root, 'inbox', 'Research', 'Projects/Research') + + for (const [oldPath, createdAt] of [ + [first, ORIGINAL_CREATED_AT], + [nested, ORIGINAL_CREATED_AT + 1000] + ] as const) { + const newPath = oldPath.replace('inbox/Research/', 'inbox/Projects/Research/') + expect((await readNote(root, newPath)).createdAt).toBe(createdAt) + expect(await readMetadata(root, newPath)).toEqual({ version: 1, createdAt }) + expect(await readFile(path.join(root, newPath))).toEqual(Buffer.from(ORIGINAL_BODY)) + await expect(readFile(metadataPath(root, oldPath))).rejects.toMatchObject({ code: 'ENOENT' }) + } + expect((await readNote(root, neighbour)).createdAt).toBe(ORIGINAL_CREATED_AT + 2000) + expect(await readMetadata(root, neighbour)).toEqual({ version: 1, createdAt: ORIGINAL_CREATED_AT + 2000 }) + }) + + it('preserves the creation date through remapped Trash and restore', async () => { + const root = await makeVault() + const originalPath = 'Notes/Projects/Original.md' + const trashPath = 'Deleted notes/Projects/Original.md' + await seedNote(root, originalPath) + const settings = await getVaultSettings(root) + await setVaultSettings(root, { + ...settings, + primaryNotesLocation: 'inbox', + systemFolderPaths: { inbox: 'Notes', trash: 'Deleted notes' } + }) + + const trashed = await moveToTrash(root, originalPath) + + expect(trashed.path).toBe(trashPath) + expect(trashed.createdAt).toBe(ORIGINAL_CREATED_AT) + expect(await readMetadata(root, trashPath)).toEqual({ version: 1, createdAt: ORIGINAL_CREATED_AT }) + await expect(readFile(metadataPath(root, originalPath))).rejects.toMatchObject({ code: 'ENOENT' }) + + const restored = await restoreFromTrash(root, trashed.path) + + expect(restored.path).toBe(originalPath) + expect(restored.createdAt).toBe(ORIGINAL_CREATED_AT) + expect((await readNote(root, originalPath)).body).toBe(ORIGINAL_BODY) + expect(await readMetadata(root, originalPath)).toEqual({ version: 1, createdAt: ORIGINAL_CREATED_AT }) + await expect(readFile(metadataPath(root, trashPath))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('removes creation metadata on permanent deletion so a new note cannot inherit the old date', async () => { + const root = await makeVault() + const notePath = 'inbox/Original.md' + await seedNote(root, notePath) + + await deleteNote(root, notePath) + + await expect(readFile(path.join(root, notePath))).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(metadataPath(root, notePath))).rejects.toMatchObject({ code: 'ENOENT' }) + const beforeCreate = Date.now() - 1000 + const recreated = await createNote(root, 'inbox', 'Original') + expect(recreated.path).toBe(notePath) + expect(recreated.createdAt).not.toBe(ORIGINAL_CREATED_AT) + expect(recreated.createdAt).toBeGreaterThanOrEqual(beforeCreate) + expect(recreated.createdAt).toBeLessThanOrEqual(Date.now() + 1000) + expect((await readNote(root, notePath)).createdAt).toBe(recreated.createdAt) + }) + + it.each([ + ['malformed JSON', '{"version":'], + ['unsupported version', JSON.stringify({ version: 2, createdAt: ORIGINAL_CREATED_AT })], + ['missing creation date', JSON.stringify({ version: 1 })], + ['nonpositive creation date', JSON.stringify({ version: 1, createdAt: 0 })], + ['fractional creation date', JSON.stringify({ version: 1, createdAt: 1234.5 })], + ['string creation date', JSON.stringify({ version: 1, createdAt: String(ORIGINAL_CREATED_AT) })] + ])('fails a save before changing Markdown when creation metadata contains %s', async (_label, corruptMetadata) => { + const root = await makeVault() + const notePath = 'inbox/Original.md' + await seedNote(root, notePath) + await writeFile(metadataPath(root, notePath), corruptMetadata) + + await expect(writeNote(root, notePath, '# This save must fail\n')).rejects.toThrow() + + expect(await readFile(path.join(root, notePath))).toEqual(Buffer.from(ORIGINAL_BODY)) + expect(await readFile(metadataPath(root, notePath), 'utf8')).toBe(corruptMetadata) + }) +}) + + +const vaultClients = [ + ['desktop', { readNote, writeNote, renameNote, renameFolder }], + ['MCP', mcpVault] +] as const + +describe.each(vaultClients)('%s creation metadata safety', (_name, client) => { + it.each(['sidecar', 'metadata directory'] as const)( + 'does not trust or write through an external %s symlink', + async kind => { + const root = await makeVault() + const outside = await mkdtemp(path.join(os.tmpdir(), 'zn-external-metadata-')) + roots.push(outside) + const notePath = 'inbox/Original.md' + await seedNote(root, notePath) + const outsideMetadata = path.join(outside, 'inbox', 'Original.md.metadata.json') + const outsideBytes = JSON.stringify({ version: 1, createdAt: ORIGINAL_CREATED_AT }) + await mkdir(path.dirname(outsideMetadata), { recursive: true }) + await writeFile(outsideMetadata, outsideBytes) + const linkPath = kind === 'sidecar' + ? metadataPath(root, notePath) + : path.join(root, '.zennotes', 'note-metadata') + const linkTarget = kind === 'sidecar' ? outsideMetadata : outside + await rm(linkPath, { force: true, recursive: kind === 'metadata directory' }) + await symlink(linkTarget, linkPath, kind === 'sidecar' ? 'file' : 'dir') + const native = await stat(path.join(root, notePath)) + const nativeCreatedAt = Math.trunc(native.birthtimeMs || native.ctimeMs) + + const loaded = await client.readNote(root, notePath) + + expect(Math.trunc(loaded.createdAt)).toBe(nativeCreatedAt) + expect(loaded.createdAt).not.toBe(ORIGINAL_CREATED_AT) + expect(loaded.body).toBe(ORIGINAL_BODY) + await expect(client.writeNote(root, notePath, '# Must not escape\n')).rejects.toThrow(/metadata|symlink/i) + expect(await readFile(path.join(root, notePath))).toEqual(Buffer.from(ORIGINAL_BODY)) + expect(await readFile(outsideMetadata, 'utf8')).toBe(outsideBytes) + expect(await readlink(linkPath)).toBe(linkTarget) + } + ) + + it('refuses a note rename onto orphan destination metadata and preserves both dates', async () => { + const root = await makeVault() + const sourcePath = 'inbox/Original.md' + const targetPath = 'inbox/Destination.md' + await seedNote(root, sourcePath) + const orphanDate = ORIGINAL_CREATED_AT + 1000 + await writeFile(metadataPath(root, targetPath), JSON.stringify({ version: 1, createdAt: orphanDate })) + + await expect(client.renameNote(root, sourcePath, 'Destination')).rejects.toThrow() + + expect(await readFile(path.join(root, sourcePath))).toEqual(Buffer.from(ORIGINAL_BODY)) + expect(await readMetadata(root, sourcePath)).toEqual({ version: 1, createdAt: ORIGINAL_CREATED_AT }) + expect(await readMetadata(root, targetPath)).toEqual({ version: 1, createdAt: orphanDate }) + await expect(readFile(path.join(root, targetPath))).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await client.readNote(root, sourcePath)).createdAt).toBe(ORIGINAL_CREATED_AT) + }) + + it('refuses a folder move onto an orphan metadata tree without moving the source', async () => { + const root = await makeVault() + const sourcePath = 'inbox/Research/Subfolder/Original.md' + const targetPath = 'inbox/Destination/Subfolder/Original.md' + await seedNote(root, sourcePath) + const orphanDate = ORIGINAL_CREATED_AT + 1000 + await mkdir(path.dirname(metadataPath(root, targetPath)), { recursive: true }) + await writeFile(metadataPath(root, targetPath), JSON.stringify({ version: 1, createdAt: orphanDate })) + + await expect(client.renameFolder(root, 'inbox', 'Research', 'Destination')).rejects.toThrow() + + expect(await readFile(path.join(root, sourcePath))).toEqual(Buffer.from(ORIGINAL_BODY)) + expect(await readMetadata(root, sourcePath)).toEqual({ version: 1, createdAt: ORIGINAL_CREATED_AT }) + expect(await readMetadata(root, targetPath)).toEqual({ version: 1, createdAt: orphanDate }) + await expect(stat(path.join(root, 'inbox', 'Destination'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await client.readNote(root, sourcePath)).createdAt).toBe(ORIGINAL_CREATED_AT) + }) +}) + +describe('creation metadata compatibility between desktop and MCP', () => { + it('retains a sidecar-free note native creation date at millisecond precision across repeated saves', async () => { + const root = await makeVault() + const notePath = 'inbox/Original.md' + await mkdir(path.join(root, 'inbox'), { recursive: true }) + await writeFile(path.join(root, notePath), ORIGINAL_BODY) + const native = await stat(path.join(root, notePath)) + const nativeCreatedAt = Math.trunc(native.birthtimeMs || native.ctimeMs) + expect(Math.trunc((await readNote(root, notePath)).createdAt)).toBe(nativeCreatedAt) + await expect(readFile(metadataPath(root, notePath))).rejects.toMatchObject({ code: 'ENOENT' }) + + for (const body of ['# First save. \r\n', '# Second café 😀\r\n']) { + expect((await writeNote(root, notePath, body)).createdAt).toBe(nativeCreatedAt) + expect((await readNote(root, notePath)).createdAt).toBe(nativeCreatedAt) + expect((await mcpVault.readNote(root, notePath)).createdAt).toBe(nativeCreatedAt) + expect(await readFile(path.join(root, notePath))).toEqual(Buffer.from(body)) + expect(await readMetadata(root, notePath)).toEqual({ version: 1, createdAt: nativeCreatedAt }) + } + }) + + it('MCP reads, renames, trashes, and restores the same creation date after a desktop save', async () => { + const root = await makeVault() + await writeFile(path.join(environment.userData, 'config.toml'), '[editor]\nsync_title_heading_on_rename = false\n') + const originalPath = 'inbox/Projects/Original.md' + await seedNote(root, originalPath) + const desktopBody = '# Saved by desktop café. \r\n\r\nUnchanged during moves.\r\n' + await writeNote(root, originalPath, desktopBody) + + expect((await mcpVault.readNote(root, originalPath)).createdAt).toBe(ORIGINAL_CREATED_AT) + const renamed = await mcpVault.renameNote(root, originalPath, 'Renamed') + expect(renamed.path).toBe('inbox/Projects/Renamed.md') + expect(renamed.createdAt).toBe(ORIGINAL_CREATED_AT) + expect((await readNote(root, renamed.path)).createdAt).toBe(ORIGINAL_CREATED_AT) + await expect(readFile(metadataPath(root, originalPath))).rejects.toMatchObject({ code: 'ENOENT' }) + + const trashed = await mcpVault.moveToTrash(root, renamed.path) + expect(trashed.path).toBe('trash/Projects/Renamed.md') + expect(trashed.createdAt).toBe(ORIGINAL_CREATED_AT) + expect((await readNote(root, trashed.path)).createdAt).toBe(ORIGINAL_CREATED_AT) + await expect(readFile(metadataPath(root, renamed.path))).rejects.toMatchObject({ code: 'ENOENT' }) + + const restored = await mcpVault.restoreFromTrash(root, trashed.path) + expect(restored.path).toBe(renamed.path) + expect(restored.createdAt).toBe(ORIGINAL_CREATED_AT) + expect((await readNote(root, restored.path)).body).toBe(desktopBody) + expect(await readMetadata(root, restored.path)).toEqual({ version: 1, createdAt: ORIGINAL_CREATED_AT }) + await expect(readFile(metadataPath(root, trashed.path))).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/apps/desktop/src/main/vault-trash-system.test.ts b/apps/desktop/src/main/vault-trash-system.test.ts index 0d0250c8..0177e959 100644 --- a/apps/desktop/src/main/vault-trash-system.test.ts +++ b/apps/desktop/src/main/vault-trash-system.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -13,7 +13,7 @@ vi.mock('electron', () => ({ shell: { trashItem: (abs: string) => trashItem(abs) } })) -const { ensureVaultLayout, listNotes, trashNoteToSystem } = await import('./vault') +const { ensureVaultLayout, listNotes, trashNoteToSystem, writeNoteComments, readNoteComments } = await import('./vault') const roots: string[] = [] afterEach(async () => { @@ -49,3 +49,20 @@ describe('trashNoteToSystem (temporary folder sessions, #650)', () => { expect(trashItem).not.toHaveBeenCalled() }) }) + + +it('restores comment paths if system Trash fails and detaches them on success',async()=>{ + const root=await mkdtemp(path.join(os.tmpdir(),'zen-system-trash-comments-')) + roots.push(root) + await ensureVaultLayout(root) + await writeFile(path.join(root,'inbox/One.md'),'Keep café. \n') + await writeNoteComments(root,'inbox/One.md',[{notePath:'inbox/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + trashItem.mockRejectedValueOnce(new Error('OS refused')) + await expect(trashNoteToSystem(root,'inbox/One.md')).rejects.toThrow('OS refused') + expect(await readFile(path.join(root,'inbox/One.md'),'utf8')).toBe('Keep café. \n') + expect(await readNoteComments(root,'inbox/One.md')).toHaveLength(1) + await trashNoteToSystem(root,'inbox/One.md') + await writeFile(path.join(root,'inbox/One.md'),'New note') + expect(await readNoteComments(root,'inbox/One.md')).toEqual([]) + expect(await readFile(path.join(root,'inbox/One.md.in-system-trash'),'utf8')).toBe('Keep café. \n') +}) diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index a93c78c7..84ca5e2b 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -9,8 +9,10 @@ import { appendToNote, archiveNote, deleteAsset, + deleteNote, duplicateAsset, emptyDeletedAssets, + emptyTrash, ensureVaultLayout, folderForRelativePath, forgetLocalVault, @@ -25,11 +27,16 @@ import { listFolders, migrateLooseAssets, moveAsset, + moveNote, + renameNote, moveToTrash, rememberLocalVault, purgeDeletedAsset, renameAsset, renameFolder, + deleteFolder, + readNoteComments, + writeNoteComments, restoreDeletedAsset, restoreFromTrash, rootContentHiddenByInboxMode, @@ -43,6 +50,8 @@ import { writeNote } from './vault' +import { registerEphemeralRoot, unregisterEphemeralRoot } from './ephemeral-vaults' + const tempDirs: string[] = [] async function makeTempDir(prefix: string): Promise { @@ -1391,3 +1400,268 @@ describe('writeNote atomic-save fidelity (#585)', () => { expect(isAtomicWriteTempPath('inbox/report.2024.01.tmp')).toBe(false) }) }) + + +describe('folder comment storage', () => { + it.each(['inbox', 'root'] as const)('moves and deletes nested comments in %s mode', async (location) => { + const root = await makeTempDir('zennotes-folder-comments-') + await ensureVaultLayout(root) + const settings = await getVaultSettings(root) + await setVaultSettings(root, { ...settings, primaryNotesLocation: location, systemFolderPaths: { inbox: 'My Notes' } }) + const prefix = location === 'root' ? '' : 'My Notes/' + const original = `${prefix}Work/Nested/Note.md` + await writeNote(root, original, 'Body.\n') + await writeNoteComments(root, original, [{ notePath: 'inbox/Work/Note.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'comment', body: 'Keep this comment', createdAt: 1, updatedAt: 1 }]) + await renameFolder(root, 'inbox', 'Work', 'Renamed') + const renamed = `${prefix}Renamed/Nested/Note.md` + expect(await readNoteComments(root, renamed)).toMatchObject([{ id: 'comment', body: 'Keep this comment', notePath: renamed }]) + expect(await readNoteComments(root, original)).toEqual([]) + await deleteFolder(root, 'inbox', 'Renamed') + await writeNote(root, renamed, 'New note.\n') + expect(await readNoteComments(root, renamed)).toEqual([]) + }) + it('rejects missing sources and comment collisions without moving notes', async () => { + const root = await makeTempDir('zennotes-folder-comments-collision-') + await ensureVaultLayout(root) + await expect(renameFolder(root, 'inbox', 'Missing', 'New')).rejects.toThrow() + await writeNote(root, 'inbox/Work/Note.md', 'Original') + await writeNoteComments(root, 'inbox/Renamed/Note.md', [{ notePath: 'inbox/Work/Note.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'orphan', body: 'Retain orphan', createdAt: 1, updatedAt: 1 }]) + await expect(renameFolder(root, 'inbox', 'Work', 'Renamed')).rejects.toThrow('comments already exist') + expect(await readFile(path.join(root, 'inbox/Work/Note.md'), 'utf8')).toBe('Original') + expect(await readNoteComments(root, 'inbox/Renamed/Note.md')).toHaveLength(1) + }) + + it.each(['rename', 'delete'] as const)('rolls back content if the %s comment move fails', async (operation) => { + const root = await makeTempDir('zennotes-folder-comments-rollback-') + await ensureVaultLayout(root) + await writeNote(root, 'inbox/Work/Note.md', 'Original') + await writeNoteComments(root, 'inbox/Work/Note.md', [{ notePath: 'inbox/Work/Note.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'comment', body: 'Keep', createdAt: 1, updatedAt: 1 }]) + const originalRename = fsPromises.rename + const spy = vi.spyOn(fsPromises, 'rename').mockImplementation(async (from, to) => { + if (String(from) === path.join(root, '.zennotes/comments/inbox/Work')) throw new Error('Comment move failed') + return originalRename(from, to) + }) + try { + await expect(operation === 'rename' ? renameFolder(root, 'inbox', 'Work', 'Renamed') : deleteFolder(root, 'inbox', 'Work')).rejects.toThrow('Comment move failed') + } finally { spy.mockRestore() } + expect(await readFile(path.join(root, 'inbox/Work/Note.md'), 'utf8')).toBe('Original') + expect(await readNoteComments(root, 'inbox/Work/Note.md')).toHaveLength(1) + }) + + it('retains comments through a case-only folder rename', async () => { + const root = await makeTempDir('zennotes-folder-comments-case-') + await ensureVaultLayout(root) + await writeNote(root, 'inbox/Work/Note.md', 'Original') + await writeNoteComments(root, 'inbox/Work/Note.md', [{ notePath: 'inbox/Work/Note.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'comment', body: 'Keep', createdAt: 1, updatedAt: 1 }]) + await renameFolder(root, 'inbox', 'Work', 'work') + expect(await readNoteComments(root, 'inbox/work/Note.md')).toMatchObject([{ notePath: 'inbox/work/Note.md' }]) + }) + + it.each([false, true])('deletes temporary-session folders without creating state (existing comments: %s)', async (withComments) => { + const root = await makeTempDir('zennotes-folder-ephemeral-') + if (withComments) { + await ensureVaultLayout(root) + await writeNote(root, 'inbox/Work/Note.md', 'Original') + await writeNoteComments(root, 'inbox/Work/Note.md', [{ notePath: 'inbox/Work/Note.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'comment', body: 'Remove', createdAt: 1, updatedAt: 1 }]) + } else { + await mkdir(path.join(root, 'inbox/Work'), { recursive: true }) + await writeFile(path.join(root, 'inbox/Work/Note.md'), 'Original') + } + registerEphemeralRoot(root) + try { + await deleteFolder(root, 'inbox', 'Work') + await expect(stat(path.join(root, 'inbox/Work'))).rejects.toMatchObject({ code: 'ENOENT' }) + if (withComments) expect(await readNoteComments(root, 'inbox/Work/Note.md')).toEqual([]) + else await expect(stat(path.join(root, '.zennotes'))).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { unregisterEphemeralRoot(root) } + }) + + it('deletes a folder from a fresh vault without private metadata', async () => { + const root = await makeTempDir('zennotes-folder-fresh-') + await mkdir(path.join(root, 'inbox/Work'), { recursive: true }) + await writeFile(path.join(root, 'inbox/Work/Note.md'), 'Original') + await deleteFolder(root, 'inbox', 'Work') + await expect(stat(path.join(root, 'inbox/Work'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + +}) + + +describe('note move transaction', () => { + it.each([false, true])('retains the source when destination comments already exist (source comments: %s)', async (withComments) => { + const root = await makeTempDir('zennotes-note-move-') + await ensureVaultLayout(root) + await writeNote(root, 'inbox/One.md', 'Original café. \n') + if (withComments) await writeNoteComments(root, 'inbox/One.md', [{notePath:'inbox/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'source', body:'Source discussion', createdAt:1, updatedAt:1}]) + await writeNoteComments(root, 'inbox/Work/One.md', [{notePath:'inbox/Work/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'destination', body:'Keep destination', createdAt:1, updatedAt:1}]) + await expect(moveNote(root,'inbox/One.md','inbox','Work')).rejects.toThrow() + expect(await readFile(path.join(root,'inbox/One.md'),'utf8')).toBe('Original café. \n') + await expect(stat(path.join(root,'inbox/Work/One.md'))).rejects.toMatchObject({code:'ENOENT'}) + expect((await readNoteComments(root,'inbox/Work/One.md'))[0].body).toBe('Keep destination') + if (withComments) expect((await readNoteComments(root,'inbox/One.md'))[0].body).toBe('Source discussion') + }) +}) + +it('numbers a moved drawing without replacing the existing drawing', async () => { + const root=await makeTempDir('zennotes-drawing-move-') + await ensureVaultLayout(root) + await mkdir(path.join(root,'inbox/Work'),{recursive:true}) + await writeFile(path.join(root,'inbox/Sketch.excalidraw'),'source drawing','utf8') + await writeFile(path.join(root,'inbox/Work/Sketch.excalidraw'),'existing drawing','utf8') + const moved=await moveNote(root,'inbox/Sketch.excalidraw','inbox','Work') + expect(moved.path).toBe('inbox/Work/Sketch 2.excalidraw') + expect(await readFile(path.join(root,moved.path),'utf8')).toBe('source drawing') + expect(await readFile(path.join(root,'inbox/Work/Sketch.excalidraw'),'utf8')).toBe('existing drawing') +}) + +it('rolls a moved note back when moving its comment file fails', async () => { + const root=await makeTempDir('zennotes-note-rollback-') + await ensureVaultLayout(root) + await writeNote(root,'inbox/One.md','Keep source.\n') + await writeNoteComments(root,'inbox/One.md',[{notePath:'inbox/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + const rename=fsPromises.rename + const spy=vi.spyOn(fsPromises,'rename').mockImplementation(async(from,to)=>{ + if (String(from).endsWith('One.md.comments.json')) throw new Error('Comment move failed') + return rename(from,to) + }) + try { await expect(moveNote(root,'inbox/One.md','inbox','Work')).rejects.toThrow('Comment move failed') } + finally { spy.mockRestore() } + expect(await readFile(path.join(root,'inbox/One.md'),'utf8')).toBe('Keep source.\n') + expect((await readNoteComments(root,'inbox/One.md'))[0].body).toBe('Keep discussion') + await expect(stat(path.join(root,'inbox/Work/One.md'))).rejects.toMatchObject({code:'ENOENT'}) +}) + +describe('note rename transaction', () => { + it.each([false, true])('retains the source when destination comments already exist (source comments: %s)', async (withComments) => { + const root = await makeTempDir('zennotes-note-rename-') + await ensureVaultLayout(root) + await writeNote(root, 'inbox/One.md', 'Original café. \n') + if (withComments) await writeNoteComments(root, 'inbox/One.md', [{notePath:'inbox/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'source', body:'Source discussion', createdAt:1, updatedAt:1}]) + await writeNoteComments(root, 'inbox/Renamed.md', [{notePath:'inbox/Renamed.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'destination', body:'Keep destination', createdAt:1, updatedAt:1}]) + await expect(renameNote(root,'inbox/One.md','Renamed')).rejects.toThrow() + expect(await readFile(path.join(root,'inbox/One.md'),'utf8')).toBe('Original café. \n') + await expect(stat(path.join(root,'inbox/Renamed.md'))).rejects.toMatchObject({code:'ENOENT'}) + expect((await readNoteComments(root,'inbox/Renamed.md'))[0].body).toBe('Keep destination') + if (withComments) expect((await readNoteComments(root,'inbox/One.md'))[0].body).toBe('Source discussion') + }) +}) + + +it('rolls a renamed note back when moving its comment file fails', async () => { + const root=await makeTempDir('zennotes-note-rollback-') + await ensureVaultLayout(root) + await writeNote(root,'inbox/One.md','Keep source.\n') + await writeNoteComments(root,'inbox/One.md',[{notePath:'inbox/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + const rename=fsPromises.rename + const spy=vi.spyOn(fsPromises,'rename').mockImplementation(async(from,to)=>{ + if (String(from).endsWith('One.md.comments.json')) throw new Error('Comment move failed') + return rename(from,to) + }) + try { await expect(renameNote(root,'inbox/One.md','Renamed')).rejects.toThrow('Comment move failed') } + finally { spy.mockRestore() } + expect(await readFile(path.join(root,'inbox/One.md'),'utf8')).toBe('Keep source.\n') + expect((await readNoteComments(root,'inbox/One.md'))[0].body).toBe('Keep discussion') + await expect(stat(path.join(root,'inbox/Renamed.md'))).rejects.toMatchObject({code:'ENOENT'}) +}) +it('renames note comments and inbound links, including a case-only rename', async () => { + const root = await makeTempDir('zennotes-note-rename-links-') + await ensureVaultLayout(root) + await writeNote(root, 'inbox/One.md', 'Original café. \n') + await writeNote(root, 'inbox/Links.md', 'See [[One#Heading|alias]] and `[[One]]`.\n') + await writeNoteComments(root, 'inbox/One.md', [{ notePath: 'inbox/One.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'comment', body: 'Keep discussion', createdAt: 1, updatedAt: 1 }]) + const renamed = await renameNote(root, 'inbox/One.md', 'one') + expect(renamed.path).toBe('inbox/one.md') + expect(await readFile(path.join(root, renamed.path), 'utf8')).toBe('Original café. \n') + expect(await readFile(path.join(root, 'inbox/Links.md'), 'utf8')).toBe('See [[one#Heading|alias]] and `[[One]]`.\n') + expect((await readNoteComments(root, renamed.path))[0]).toMatchObject({ notePath: renamed.path, body: 'Keep discussion' }) +}) + + +describe('note lifecycle transactions', () => { + it.each([ + ['archive', archiveNote, 'inbox/One.md', 'archive/One.md'], + ['trash', moveToTrash, 'inbox/One.md', 'trash/One.md'], + ['unarchive', unarchiveNote, 'archive/One.md', 'inbox/One.md'], + ['restore', restoreFromTrash, 'trash/One.md', 'inbox/One.md'] + ] as const)('rolls back %s when the comment move fails', async (_name, action, source, target) => { + const root=await makeTempDir('zennotes-lifecycle-') + await ensureVaultLayout(root) + await writeNote(root,source,'Keep café. \n') + await writeNoteComments(root,source,[{notePath:source,anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + const rename=fsPromises.rename + const spy=vi.spyOn(fsPromises,'rename').mockImplementation(async(from,to)=>{ + if(String(from).endsWith('One.md.comments.json')) throw new Error('Comment move failed') + return rename(from,to) + }) + try {await expect(action(root,source)).rejects.toThrow('Comment move failed')} + finally {spy.mockRestore()} + expect(await readFile(path.join(root,source),'utf8')).toBe('Keep café. \n') + expect(await readNoteComments(root,source)).toHaveLength(1) + await expect(stat(path.join(root,target))).rejects.toMatchObject({code:'ENOENT'}) + }) + + it('retains a permanently deleted note if detaching its comments fails', async () => { + const root=await makeTempDir('zennotes-lifecycle-delete-') + await ensureVaultLayout(root) + await writeNote(root,'trash/One.md','Keep source.\n') + await writeNoteComments(root,'trash/One.md',[{notePath:'trash/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + const rename=fsPromises.rename + const spy=vi.spyOn(fsPromises,'rename').mockImplementation(async(from,to)=>{ + if(String(from).endsWith('One.md.comments.json')) throw new Error('Comment move failed') + return rename(from,to) + }) + try {await expect(deleteNote(root,'trash/One.md')).rejects.toThrow('Comment move failed')} + finally {spy.mockRestore()} + expect(await readFile(path.join(root,'trash/One.md'),'utf8')).toBe('Keep source.\n') + expect(await readNoteComments(root,'trash/One.md')).toHaveLength(1) + await deleteNote(root,'trash/One.md') + await writeNote(root,'trash/One.md','New note.\n') + expect(await readNoteComments(root,'trash/One.md')).toEqual([]) + }) +}) + + +it.each([['archive', archiveNote], ['trash', moveToTrash]] as const)('preserves drawings with colliding %s filenames',async(folder,action)=>{ + const root=await makeTempDir('zennotes-lifecycle-drawing-') + await ensureVaultLayout(root) + await writeFile(path.join(root,'inbox/Sketch.excalidraw'),'source drawing') + await writeFile(path.join(root,folder,'Sketch.excalidraw'),'existing drawing') + const meta=await action(root,'inbox/Sketch.excalidraw') + expect(meta.path).toBe(`${folder}/Sketch 2.excalidraw`) + expect(await readFile(path.join(root,meta.path),'utf8')).toBe('source drawing') + expect(await readFile(path.join(root,folder,'Sketch.excalidraw'),'utf8')).toBe('existing drawing') +}) + + +describe('Empty Trash transaction',()=>{ + it.each(['root','inbox'] as const)('clears the remapped Trash and nested comments in %s mode',async location=>{ + const root=await makeTempDir('zennotes-empty-trash-') + await ensureVaultLayout(root) + const settings=await getVaultSettings(root) + await setVaultSettings(root,{...settings,primaryNotesLocation:location,systemFolderPaths:{trash:'Deleted files'}}) + await writeNote(root,'Deleted files/Nested/One.md','Delete me') + await writeNoteComments(root,'Deleted files/Nested/One.md',[{notePath:'Deleted files/Nested/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Remove discussion',createdAt:1,updatedAt:1}]) + await writeNote(root,'trash/Unrelated.md','Keep literal trash folder') + await emptyTrash(root) + await expect(stat(path.join(root,'Deleted files/Nested/One.md'))).rejects.toMatchObject({code:'ENOENT'}) + expect(await readNoteComments(root,'Deleted files/Nested/One.md')).toEqual([]) + expect(await readFile(path.join(root,'trash/Unrelated.md'),'utf8')).toBe('Keep literal trash folder') + await emptyTrash(root) + }) + it('rolls back every trashed file if moving the comment tree fails',async()=>{ + const root=await makeTempDir('zennotes-empty-trash-rollback-') + await ensureVaultLayout(root) + await writeNote(root,'trash/One.md','Keep me') + await writeNoteComments(root,'trash/One.md',[{notePath:'trash/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + const rename=fsPromises.rename + const spy=vi.spyOn(fsPromises,'rename').mockImplementation(async(from,to)=>{ + // Windows joins with backslashes, so compare the normalized path. + if(String(from).replace(/\\/g,'/').endsWith('.zennotes/comments/trash'))throw new Error('Comment move refused') + return rename(from,to) + }) + try {await expect(emptyTrash(root)).rejects.toThrow('Comment move refused')} + finally{spy.mockRestore()} + expect(await readFile(path.join(root,'trash/One.md'),'utf8')).toBe('Keep me') + expect(await readNoteComments(root,'trash/One.md')).toHaveLength(1) + }) +}) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 15e84e0d..2809d28f 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -1,3 +1,4 @@ +import { noteMetadataPath, readNoteCreatedAt, prepareNoteCreation, removeNoteCreation } from './note-creation-metadata' import { promises as fs, type Dirent } from 'node:fs' import { execFile, spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' @@ -1893,7 +1894,7 @@ async function folderOf(root: string, absPath: string): Promise { meta = await readMeta(root, target, folder) } + ) invalidateNoteMetaCache(root, rel) invalidateNoteMetaCache(root, meta.path) invalidateVaultTextSearchCache(root) @@ -3586,7 +3577,7 @@ async function updateInboundWikilinks( (n) => n.path !== oldPath && n.folder !== 'trash' && - (n.wikilinks ?? []).some((t) => resolveWikilinkTarget(refs, t)?.path === oldPath) + (n.wikilinks ?? []).some((t) => resolveWikilinkTarget(refs, t.split(/[|#^]/, 1)[0])?.path === oldPath) ) for (const candidate of candidates) { try { @@ -3627,14 +3618,13 @@ async function moveBetweenFolders( const targetRoot = await folderRoot(root, target) const destDir = subpath ? resolveSafe(targetRoot, subpath) : targetRoot await fs.mkdir(destDir, { recursive: true }) - const baseTitle = path.basename(filename, path.extname(filename)) - const finalTitle = await uniqueTitle(destDir, baseTitle) - // Preserve the file type when moving (a `.excalidraw` drawing stays a drawing). - const ext = isExcalidrawPath(filename) ? '.excalidraw' : '.md' - const destAbs = path.join(destDir, `${finalTitle}${ext}`) - await fs.rename(abs, destAbs) - const meta = await readMeta(root, destAbs, target) - await moveNoteComments(root, rel, meta.path) + const destAbs = path.join(destDir, await uniqueFilename(destDir, filename)) + const nextRel = toPosix(path.relative(root, destAbs)) + let meta!: NoteMeta + await relocateFolderTrees( + [[abs, destAbs], [noteCommentsPath(root, rel), noteCommentsPath(root, nextRel)], [await noteMetadataPath(root, rel), await noteMetadataPath(root, nextRel)]], + async () => { meta = await readMeta(root, destAbs, target) } + ) invalidateNoteMetaCache(root, rel) invalidateNoteMetaCache(root, meta.path) invalidateVaultTextSearchCache(root) @@ -3656,7 +3646,18 @@ export async function trashNoteToSystem(root: string, rel: string): Promise true, (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return false + throw error + }) + if (hasComments) { + const temporary = await fs.mkdtemp(path.join(root, INTERNAL_VAULT_DIR, 'note-delete-')) + await relocateFolderTrees([[comments, path.join(temporary, 'comments')]], () => shell.trashItem(abs)) + await fs.rm(temporary, { recursive: true, force: true }).catch(error => console.warn('Note cleanup pending', error)) + } else { + await shell.trashItem(abs) + } invalidateNoteMetaCache(root, rel) invalidateVaultTextSearchCache(root) return meta @@ -3676,25 +3677,45 @@ export function unarchiveNote(root: string, rel: string): Promise { export async function emptyTrash(root: string): Promise { const trashDir = await folderRoot(root, 'trash') - const settings = await getVaultSettings(root) - const trashRelPrefix = resolveFolderPath('trash', settings.systemFolderPaths) - try { - const entries = await fs.readdir(trashDir) - await Promise.all(entries.map((e) => removeNoteComments(root, `${trashRelPrefix}/${e}`))) - await Promise.all( - entries.map((e) => fs.rm(path.join(trashDir, e), { recursive: true, force: true })) - ) - invalidateNoteMetaCache(root) - invalidateVaultTextSearchCache(root) - } catch { - /* no trash dir yet */ - } + const trashRel = toPosix(path.relative(root, trashDir)) + const comments = resolveSafe(noteCommentsRoot(root), trashRel) + await fs.mkdir(path.join(root, INTERNAL_VAULT_DIR), { recursive: true }) + const temporary = await fs.mkdtemp(path.join(root, INTERNAL_VAULT_DIR, 'trash-delete-')) + await relocateFolderTrees([ + [trashDir, path.join(temporary, 'content')], + [comments, path.join(temporary, 'comments')], + [await noteMetadataPath(root, trashRel, true), path.join(temporary, 'metadata')] + ], async () => {}) + await fs.rm(temporary, { recursive: true, force: true }).catch(error => console.warn('Trash cleanup pending', error)) + invalidateNoteMetaCache(root) + invalidateVaultTextSearchCache(root) } export async function deleteNote(root: string, rel: string): Promise { const abs = resolveSafe(root, rel) - await fs.rm(abs, { force: true }) - await removeNoteComments(root, rel) + const source = await fs.lstat(abs).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error + return null + }) + if (source?.isDirectory()) throw new Error('Use the folder action to delete a directory.') + const comments = noteCommentsPath(root, rel) + const hasComments = await fs.stat(comments).then(() => true, (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return false + throw error + }) + if (isEphemeralRoot(root) && !hasComments) { + await fs.rm(abs, { force: true }) + } else { + await fs.mkdir(path.join(root, INTERNAL_VAULT_DIR), { recursive: true }) + const temporary = await fs.mkdtemp(path.join(root, INTERNAL_VAULT_DIR, 'note-delete-')) + await relocateFolderTrees([ + [abs, path.join(temporary, 'content')], + [comments, path.join(temporary, 'comments')], + [await noteMetadataPath(root, toPosix(path.relative(root, abs)), source?.isDirectory() ?? false), path.join(temporary, 'metadata')] + ], async () => {}) + // Once detached, cleanup cannot attach the old discussion to a new note. + await fs.rm(temporary, { recursive: true, force: true }).catch(error => console.warn('Note cleanup pending', error)) + } invalidateNoteMetaCache(root, rel) invalidateVaultTextSearchCache(root) } @@ -3938,9 +3959,96 @@ export async function createFolder( await fs.mkdir(abs, { recursive: true }) } +async function renameDirectory(from: string, to: string): Promise { + if (from === to) return + if (from.toLowerCase() !== to.toLowerCase()) return fs.rename(from, to) + const temporary = `${from}_rename_tmp_${randomUUID()}` + await fs.rename(from, temporary) + try { + await fs.rename(temporary, to) + } catch (error) { + try { + await fs.rename(temporary, from) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'FOLDER_STATE_UNCERTAIN: Folder change could not be rolled back; reload the vault before editing' + ) + } + throw error + } +} + +/** Move content and its parallel comments together, retaining the originals on failure. */ +async function relocateFolderTrees( + moves: Array<[string, string]>, + persistSettings: () => Promise +): Promise { + const present: Array<[string, string]> = [] + for (const [from, to] of moves) { + let source + try { + source = await fs.stat(from) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + try { + const target = await fs.stat(to) + if (!source || source.ino !== target.ino || source.dev !== target.dev) + throw new Error('The destination folder or its comments already exist') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (source) present.push([from, to]) + } + const moved: Array<[string, string]> = [] + try { + for (const [from, to] of present) { + await fs.mkdir(path.dirname(to), { recursive: true }) + await renameDirectory(from, to) + moved.push([from, to]) + } + await persistSettings() + } catch (error) { + const failures: unknown[] = [error] + for (const [from, to] of moved.reverse()) { + try { + await renameDirectory(to, from) + } catch (rollbackError) { + failures.push(rollbackError) + } + } + if (failures.length > 1) + throw new AggregateError( + failures, + 'FOLDER_STATE_UNCERTAIN: Folder change could not be rolled back; reload the vault before editing' + ) + throw error + } +} + +/** Shared local folder move for ordinary folders and database containers. */ +export async function renameFolderTrees( + root: string, oldRelative: string, newRelative: string, + persistSettings: () => Promise = async () => {} +): Promise { + const oldAbs = resolveSafe(root, oldRelative) + const newAbs = resolveSafe(root, newRelative) + if (oldAbs === root || newAbs === root) throw new Error('Cannot rename the vault root') + await fs.stat(oldAbs) + if (oldAbs === newAbs) return + if ((newAbs + path.sep).startsWith(oldAbs + path.sep)) throw new Error('Cannot move a folder into itself') + const oldComments = resolveSafe(noteCommentsRoot(root), toPosix(path.relative(root, oldAbs))) + const newComments = resolveSafe(noteCommentsRoot(root), toPosix(path.relative(root, newAbs))) + await relocateFolderTrees([[oldAbs, newAbs], [oldComments, newComments], + [await noteMetadataPath(root, oldRelative, true), await noteMetadataPath(root, newRelative, true)]], persistSettings) + invalidateNoteMetaCache(root) + invalidateVaultTextSearchCache(root) +} + /** * Rename or move a subfolder. `newSubpath` is the full target path - * relative to `{topFolder}` — e.g. rename `Work/Research` → `Projects/Research` + * relative to `{topFolder}`, for example rename `Work/Research` → `Projects/Research` * also moves it into `Projects`. Refuses to move into itself or a * descendant, and refuses to touch the top-level folder. */ @@ -3958,6 +4066,7 @@ export async function renameFolder( const topRoot = await folderRoot(root, topFolder) const oldAbs = resolveSafe(topRoot, oldClean) const newAbs = resolveSafe(topRoot, newClean) + await fs.stat(oldAbs) if (newAbs === oldAbs) return newClean const sep = path.sep @@ -3979,35 +4088,13 @@ export async function renameFolder( if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e } - await fs.mkdir(path.dirname(newAbs), { recursive: true }) - // On case-insensitive filesystems, a direct rename('AI','ai') may - // not change the case. Use a two-step rename via a temp name. - if (oldAbs.toLowerCase() === newAbs.toLowerCase() && oldAbs !== newAbs) { - const tmpAbs = oldAbs + '_rename_tmp_' + Date.now() - await fs.rename(oldAbs, tmpAbs) - await fs.rename(tmpAbs, newAbs) - } else { - await fs.rename(oldAbs, newAbs) - } const settings = await getVaultSettings(root) const nextSettings: VaultSettings = { ...settings, - folderIcons: rewriteFolderIconsForRename( - settings.folderIcons, - topFolder, - oldClean, - newClean - ), - folderColors: rewriteFolderColorsForRename( - settings.folderColors, - topFolder, - oldClean, - newClean - ) + folderIcons: rewriteFolderIconsForRename(settings.folderIcons, topFolder, oldClean, newClean), + folderColors: rewriteFolderColorsForRename(settings.folderColors, topFolder, oldClean, newClean) } - await setVaultSettings(root, nextSettings) - invalidateNoteMetaCache(root) - invalidateVaultTextSearchCache(root) + await renameFolderTrees(root, toPosix(path.relative(root, oldAbs)), toPosix(path.relative(root, newAbs)), () => setVaultSettings(root, nextSettings)) return newClean } @@ -4023,14 +4110,37 @@ export async function deleteFolder( const clean = subpath.replace(/^\/+|\/+$/g, '') if (!clean) throw new Error('Cannot delete the top-level folder') const abs = resolveSafe(await folderRoot(root, topFolder), clean) - await fs.rm(abs, { recursive: true, force: true }) + const comments = resolveSafe(noteCommentsRoot(root), toPosix(path.relative(root, abs))) + const hasComments = await fs.stat(comments).then(() => true, (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return false + throw error + }) + if (isEphemeralRoot(root) && !hasComments) { + await fs.rm(abs, { recursive: true, force: true }) + invalidateNoteMetaCache(root) + invalidateVaultTextSearchCache(root) + return + } const settings = await getVaultSettings(root) const nextSettings: VaultSettings = { ...settings, folderIcons: removeFolderIcons(settings.folderIcons, topFolder, clean), folderColors: removeFolderColors(settings.folderColors, topFolder, clean) } - await setVaultSettings(root, nextSettings) + await fs.mkdir(path.join(root, INTERNAL_VAULT_DIR), { recursive: true }) + const temporary = await fs.mkdtemp(path.join(root, INTERNAL_VAULT_DIR, 'folder-delete-')) + await relocateFolderTrees( + [ + [abs, path.join(temporary, 'content')], + [comments, path.join(temporary, 'comments')], + [await noteMetadataPath(root, toPosix(path.relative(root, abs)), true), path.join(temporary, 'metadata')] + ], + () => setVaultSettings(root, nextSettings) + ) + // Cleanup can be retried safely: neither tree remains at a live note path. + await fs + .rm(temporary, { recursive: true, force: true }) + .catch((error) => console.warn('Folder cleanup pending', error)) invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) } @@ -4246,13 +4356,14 @@ export async function moveNote( } await fs.mkdir(destDir, { recursive: true }) - const ext = path.extname(filename) - const baseTitle = path.basename(filename, ext) - const finalTitle = await uniqueTitle(destDir, baseTitle) - const destAbs = path.join(destDir, `${finalTitle}${ext}`) - await fs.rename(oldAbs, destAbs) - const meta = await readMeta(root, destAbs, targetFolder) - await moveNoteComments(root, oldRel, meta.path) + const finalName = await uniqueFilename(destDir, filename) + const destAbs = path.join(destDir, finalName) + const nextRel = toPosix(path.relative(root, destAbs)) + let meta!: NoteMeta + await relocateFolderTrees( + [[oldAbs, destAbs], [noteCommentsPath(root, oldRel), noteCommentsPath(root, nextRel)], [await noteMetadataPath(root, oldRel), await noteMetadataPath(root, nextRel)]], + async () => { meta = await readMeta(root, destAbs, targetFolder) } + ) invalidateNoteMetaCache(root, oldRel) invalidateNoteMetaCache(root, meta.path) invalidateVaultTextSearchCache(root) @@ -4269,6 +4380,7 @@ export async function duplicateNote(root: string, rel: string): Promise trimmed.toLowerCase().startsWith(`${folder}/`))) { - relPath = `${trimmed}.md` - } - if (!relPath) return null - - const needle = normalizeForCompare(relPath) - return notes.find((note) => normalizeForCompare(note.path) === needle) ?? null -} - -function resolvePathSuffix(notes: RenameNoteRef[], target: string): RenameNoteRef | null { - const trimmed = stripMdExtension(normalizeSlashes(target.trim())) - .replace(/^\/+/, '') - .replace(/\/+$/, '') - if (!trimmed) return null - - const suffix = normalizeForCompare(`/${trimmed}.md`) - const exact = normalizeForCompare(`${trimmed}.md`) - const matches = notes.filter((note) => { - const path = normalizeForCompare(note.path) - return path === exact || path.endsWith(suffix) - }) - return matches.length === 1 ? matches[0] : null -} - -export function resolveWikilinkTarget( - notes: RenameNoteRef[], - target: string -): RenameNoteRef | null { - const visible = notes.filter((note) => note.folder !== 'trash') - if (isPathLikeWikilinkTarget(target)) { - return resolveExplicitPath(visible, target) ?? resolvePathSuffix(visible, target) - } - const needle = normalizeForCompare(stripMdExtension(target)) - return visible.find((note) => normalizeForCompare(note.title) === needle) ?? null -} - -/** Split `[[ ... ]]` inner text into target, `#heading`/`^block` anchor, and - * `|alias` — the anchor/alias keep their leading delimiter so the link can be - * reassembled verbatim. */ -function splitWikilinkContent(content: string): { - target: string - anchor: string - alias: string -} { - let rest = content - let alias = '' - const pipe = rest.indexOf('|') - if (pipe >= 0) { - alias = rest.slice(pipe) - rest = rest.slice(0, pipe) - } - let anchor = '' - const anchorIdx = rest.search(/[#^]/) - if (anchorIdx >= 0) { - anchor = rest.slice(anchorIdx) - rest = rest.slice(0, anchorIdx) - } - return { target: rest, anchor, alias } -} - -/** Replace a wikilink target's final segment (the renamed file's name) with the - * new title, preserving any directory prefix, leading slash, and `.md`. */ -function swapBasename(target: string, newTitle: string): string { - const slash = target.lastIndexOf('/') - const dir = slash >= 0 ? target.slice(0, slash + 1) : '' - const base = slash >= 0 ? target.slice(slash + 1) : target - const md = base.match(/\.md$/i) - return `${dir}${newTitle}${md ? md[0] : ''}` -} - -// Matches a fenced code block, inline code, or a (possibly embedded) wikilink. -// Code is matched first so links inside code spans/blocks are left untouched. -const TOKEN_RE = /(```[\s\S]*?```|`[^`\n]*`)|(!?)\[\[([^\]\n]+?)\]\]/g - -/** - * Rewrite every inbound `[[target]]` / `![[target]]` in `body` whose target - * resolves to the note at `oldPath`, pointing it at `newTitle` instead. Aliases, - * `#heading` / `^block` anchors, and embeds are preserved; code is skipped. - * - * `notes` must reflect the pre-rename vault (the renamed note still under its - * old title/path) so resolution matches what the links currently point to. - */ -export function rewriteWikilinksForRename( - body: string, - notes: RenameNoteRef[], - oldPath: string, - newTitle: string -): { body: string; changed: number } { - let changed = 0 - const next = body.replace(TOKEN_RE, (full, code, embed, content) => { - if (code !== undefined) return full - const { target, anchor, alias } = splitWikilinkContent(content as string) - if (resolveWikilinkTarget(notes, target)?.path !== oldPath) return full - const newTarget = swapBasename(target, newTitle) - if (newTarget === target) return full - changed++ - return `${embed}[[${newTarget}${anchor}${alias}]]` - }) - return { body: next, changed } -} +export { + isPathLikeWikilinkTarget, + resolveWikilinkTarget, + rewriteWikilinksForRename +} from '@zennotes/shared-domain/wikilink-rename' +export type { RenameNoteRef } from '@zennotes/shared-domain/wikilink-rename' diff --git a/apps/desktop/src/main/workflow-apply.ts b/apps/desktop/src/main/workflow-apply.ts index 14002799..1bde72e8 100644 --- a/apps/desktop/src/main/workflow-apply.ts +++ b/apps/desktop/src/main/workflow-apply.ts @@ -358,7 +358,7 @@ function stringField(record: Record, key: string): string | nul * * SYNCED COPIES: the same validator exists in @shared/workflows/prepare-run * (the web client's), and the Go server mirrors the field list in - * requiredWorkflowOpFields (apps/server/internal/vault/workflows.go). A new + * requiredWorkflowOpFields (internal/vault/workflows.go in ZenNotes/znserver). A new * op kind or field lands in all three. */ export function parseWorkflowOp(value: unknown): WorkflowOp | null { diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 266137ef..ce337c27 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -1,3 +1,4 @@ +import { readNoteCreatedAt, prepareNoteCreation, removeNoteCreation, moveWithCreationMetadata } from '../main/note-creation-metadata' /** * Vault operations used by the MCP server. Mirrors the filesystem * behavior of src/main/vault.ts, but without Electron dependencies — @@ -615,7 +616,7 @@ async function folderOf(root: string, abs: string): Promise { * nested under a list item is still a code block (#293). Mirrors * `stripCodeContent` in apps/desktop/src/main/vault.ts, * packages/app-core/src/lib/{tags,wikilinks}.ts, and - * apps/server/internal/vault/parse.go — keep all five in sync. + * internal/vault/parse.go in ZenNotes/znserver — keep all five in sync. */ function stripCodeContent(body: string): string { if (!body.includes('`') && !body.includes('~')) return body @@ -711,7 +712,7 @@ async function readMeta(root: string, abs: string, folder: NoteFolder): Promise< link: buildOpenNoteDeepLink(rel), title: path.basename(abs, path.extname(abs)), folder, - createdAt: stat.birthtimeMs || stat.ctimeMs, + createdAt: await readNoteCreatedAt(root, rel, stat.birthtimeMs || stat.ctimeMs), updatedAt: stat.mtimeMs, size: stat.size, tags: isPreamble ? [] : extractTags(body), @@ -885,6 +886,7 @@ export async function readNote(root: string, rel: string): Promise export async function writeNote(root: string, rel: string, body: string): Promise { const abs = resolveSafe(root, rel) await fs.mkdir(path.dirname(abs), { recursive: true }) + await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, body, 'utf8') const folder = await folderOf(root, abs) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) @@ -911,6 +913,7 @@ export async function readVaultFileTextOrNull(root: string, rel: string): Promis export async function writeVaultFileText(root: string, rel: string, text: string): Promise { const abs = resolveSafe(root, rel) await fs.mkdir(path.dirname(abs), { recursive: true }) + await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, text, 'utf8') } @@ -971,6 +974,7 @@ export async function createNote( const finalTitle = await uniqueTitle(dir, base) const abs = path.join(dir, `${finalTitle}.md`) const content = body ?? `# ${finalTitle}\n\n` + await removeNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, content, 'utf8') return await readMeta(root, abs, folder) } @@ -994,10 +998,10 @@ export async function renameNote(root: string, rel: string, nextTitle: string): } if (abs.toLowerCase() === target.toLowerCase() && abs !== target) { const tmp = abs + '_rename_tmp_' + Date.now() - await fs.rename(abs, tmp) - await fs.rename(tmp, target) + await moveWithCreationMetadata(root, abs, tmp) + await moveWithCreationMetadata(root, tmp, target) } else { - await fs.rename(abs, target) + await moveWithCreationMetadata(root, abs, target) } } await syncTitleHeading(abs, target, trimmed) @@ -1059,7 +1063,7 @@ async function moveBetweenFolders( const baseTitle = path.basename(filename, path.extname(filename)) const finalTitle = await uniqueTitle(destDir, baseTitle) const destAbs = path.join(destDir, `${finalTitle}.md`) - await fs.rename(abs, destAbs) + await moveWithCreationMetadata(root, abs, destAbs) return await readMeta(root, destAbs, target) } @@ -1091,7 +1095,7 @@ export async function moveNote( const baseTitle = path.basename(filename, ext) const finalTitle = await uniqueTitle(destDir, baseTitle) const destAbs = path.join(destDir, `${finalTitle}${ext}`) - await fs.rename(oldAbs, destAbs) + await moveWithCreationMetadata(root, oldAbs, destAbs) return await readMeta(root, destAbs, targetFolder) } @@ -1105,6 +1109,7 @@ export async function duplicateNote(root: string, rel: string): Promise { const abs = resolveSafe(root, rel) await fs.rm(abs, { force: true }) + await removeNoteCreation(root, toPosix(path.relative(root, abs))) } export async function emptyTrash(root: string): Promise { @@ -1119,6 +1125,7 @@ export async function emptyTrash(root: string): Promise { try { const entries = await fs.readdir(trashDir) await Promise.all(entries.map((e) => fs.rm(path.join(trashDir, e), { recursive: true, force: true }))) + await removeNoteCreation(root, toPosix(path.relative(root, trashDir)), true) } catch { /* no trash dir */ } @@ -1153,7 +1160,7 @@ export async function renameFolder( throw new Error('Cannot move a folder into itself') } await fs.mkdir(path.dirname(newAbs), { recursive: true }) - await fs.rename(oldAbs, newAbs) + await moveWithCreationMetadata(root, oldAbs, newAbs, true) return newClean } @@ -1167,6 +1174,7 @@ export async function deleteFolder( const folderAbs = await folderRoot(root, topFolder) const abs = resolveSafe(folderAbs, clean) await fs.rm(abs, { recursive: true, force: true }) + await removeNoteCreation(root, toPosix(path.relative(root, abs)), true) } /* ---------- Text search ---------------------------------------------- */ @@ -1675,6 +1683,7 @@ export async function toggleTask(root: string, taskId: string): Promise { const abs = resolveSafe(root, rel) const body = await fs.readFile(abs, 'utf8') + await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, appendToBody(body, text), 'utf8') const folder = await folderOf(root, abs) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) @@ -1830,6 +1841,7 @@ export function prependToBody(body: string, text: string): string { export async function prependToNote(root: string, rel: string, text: string): Promise { const abs = resolveSafe(root, rel) const body = await fs.readFile(abs, 'utf8') + await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, prependToBody(body, text), 'utf8') const folder = await folderOf(root, abs) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) @@ -1878,6 +1890,7 @@ export async function replaceInNote( if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return { meta: await readMeta(root, abs, folder), replacements: 0 } } + await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, next, 'utf8') const folder = await folderOf(root, abs) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) @@ -1892,6 +1905,7 @@ export async function insertAtLine( ): Promise { const abs = resolveSafe(root, rel) const body = await fs.readFile(abs, 'utf8') + await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, insertAtLineInBody(body, lineNumber, text), 'utf8') const folder = await folderOf(root, abs) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 396be024..df5f5542 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -59,6 +59,7 @@ import type { AppUpdateState, AssetMeta, CliInstallStatus, + CliInstallRequest, DeletedAsset, DirectoryBrowseResult, ExternalFileContent, @@ -123,7 +124,8 @@ const DESKTOP_APP_INFO: ZenAppInfo = { version: appPackage.version, description: appPackage.description, homepage: appPackage.homepage, - runtime: 'desktop' + runtime: 'desktop', + hostKind: 'desktop' } let remoteWorkspaceInfo: RemoteWorkspaceInfo | null = null @@ -690,7 +692,8 @@ const api: ZenBridge = { mcpSetInstructions: (next: string | null): Promise => ipcRenderer.invoke(IPC.MCP_SET_INSTRUCTIONS, next), cliGetStatus: (): Promise => ipcRenderer.invoke(IPC.CLI_GET_STATUS), - cliInstall: (): Promise => ipcRenderer.invoke(IPC.CLI_INSTALL), + cliInstall: (request?: CliInstallRequest): Promise => + ipcRenderer.invoke(IPC.CLI_INSTALL, request), cliUninstall: (): Promise => ipcRenderer.invoke(IPC.CLI_UNINSTALL), raycastGetStatus: (): Promise => ipcRenderer.invoke(IPC.RAYCAST_GET_STATUS), diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js index 1b32a4aa..16bc1b50 100644 --- a/apps/desktop/tailwind.config.js +++ b/apps/desktop/tailwind.config.js @@ -1,86 +1,5 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ['./src/renderer/index.html', '../../packages/app-core/src/**/*.{ts,tsx}'], - theme: { - extend: { - colors: { - paper: { - 50: 'rgb(var(--z-bg-softer) / )', - 100: 'rgb(var(--z-bg) / )', - 200: 'rgb(var(--z-bg-1) / )', - 300: 'rgb(var(--z-bg-2) / )', - 400: 'rgb(var(--z-bg-3) / )', - 500: 'rgb(var(--z-bg-4) / )' - }, - ink: { - 900: 'rgb(var(--z-fg) / )', - 800: 'rgb(var(--z-fg-1) / )', - 700: 'rgb(var(--z-fg-2) / )', - 600: 'rgb(var(--z-grey-2) / )', - 500: 'rgb(var(--z-grey-1) / )', - 400: 'rgb(var(--z-grey-0) / )', - 300: 'rgb(var(--z-grey-dim) / )' - }, - accent: { - DEFAULT: 'rgb(var(--z-accent) / )', - soft: 'rgb(var(--z-accent-soft) / )', - muted: 'rgb(var(--z-accent-muted) / )' - }, - danger: 'rgb(var(--z-red) / )', - success: 'rgb(var(--z-green) / )', - warning: 'rgb(var(--z-yellow) / )' - }, - borderRadius: { - // Scale every rounded-* by --z-radius-scale (default 1) so one var can - // square all corners (Quick tweaks → Square corners sets it to 0). - // rounded-none / rounded-full keep Tailwind defaults, so pills and - // circles stay round. - DEFAULT: 'calc(0.25rem * var(--z-radius-scale, 1))', - sm: 'calc(0.125rem * var(--z-radius-scale, 1))', - md: 'calc(0.375rem * var(--z-radius-scale, 1))', - lg: 'calc(0.5rem * var(--z-radius-scale, 1))', - xl: 'calc(0.75rem * var(--z-radius-scale, 1))', - '2xl': 'calc(1rem * var(--z-radius-scale, 1))', - '3xl': 'calc(1.5rem * var(--z-radius-scale, 1))' - }, - fontFamily: { - sans: [ - '-apple-system', - 'BlinkMacSystemFont', - '"SF Pro Text"', - '"Inter"', - 'system-ui', - 'sans-serif' - ], - serif: ['"Iowan Old Style"', '"Source Serif Pro"', 'Georgia', 'serif'], - mono: ['"JetBrains Mono"', '"SF Mono"', 'Menlo', 'monospace'] - }, - boxShadow: { - panel: - '0 1px 0 0 rgb(var(--z-shadow) / 0.04), 0 8px 28px -12px rgb(var(--z-shadow) / 0.18)', - float: '0 20px 60px -20px rgb(var(--z-shadow) / 0.28)' - }, - fontSize: { - '2xs': ['0.6875rem', { lineHeight: '1rem' }] - }, - zIndex: { - dropdown: '40', - palette: '50', - modal: '70', - nested: '75', - popover: '80', - toast: '90' - }, - maxWidth: { - 'dialog-xs': '420px', - 'dialog-sm': '440px', - 'dialog-md': '560px', - 'dialog-lg': '720px', - 'dialog-xl': '900px', - 'dialog-2xl': '1120px', - 'dialog-3xl': '1360px' - } - } - }, - plugins: [] + presets: [require('../../packages/app-core/build/tailwind-preset.cjs')], + content: ['./src/renderer/index.html', '../../packages/app-core/src/**/*.{ts,tsx}'] } diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json new file mode 100644 index 00000000..91cd88ed --- /dev/null +++ b/apps/desktop/terminal-release.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "release": null +} diff --git a/apps/server/cmd/zennotes-server/main.go b/apps/server/cmd/zennotes-server/main.go deleted file mode 100644 index 1bfa5a6a..00000000 --- a/apps/server/cmd/zennotes-server/main.go +++ /dev/null @@ -1,170 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "log" - "net" - "net/http" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/httpserver" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" - "github.com/ZenNotes/zennotes/apps/server/internal/watcher" - "github.com/ZenNotes/zennotes/apps/server/web" -) - -func main() { - log.SetFlags(log.LstdFlags | log.Lmicroseconds) - - cfg := config.Load() - if strings.TrimSpace(cfg.AuthToken) == "" && !cfg.AllowInsecureNoAuth && !bindIsLoopback(cfg.Bind) { - log.Fatal("refusing to start without an auth token on a non-loopback bind; set ZENNOTES_AUTH_TOKEN, point ZENNOTES_AUTH_TOKEN_FILE at a readable token file, or set ZENNOTES_ALLOW_INSECURE_NOAUTH=1 to override") - } - logStartupBanner(cfg) - - v, err := vault.New(cfg.VaultPath, vault.Options{ - FileMode: cfg.VaultFileMode, - DirMode: cfg.VaultDirMode, - MaxAssetBytes: cfg.MaxAssetBytes, - }) - if err != nil { - if errors.Is(err, os.ErrPermission) { - logVaultPermissionHelp(cfg.VaultPath, err) - os.Exit(1) - } - log.Fatalf("vault init: %v", err) - } - - if config.LegacyVaultConfigExists(v.Root()) { - log.Printf("warning: ignoring legacy vault config at %s; server secrets now stay in host config only", config.LegacyVaultConfigPath(v.Root())) - } - - // Never fatal: where inotify is restricted (e.g. unprivileged LXC) the - // watcher falls back to a no-op so the server still serves the vault. (#179) - w := watcher.StartOrDisabled(v.Root(), cfg.DisableWatcher) - defer w.Close() - - // Seed from the vault's normalized settings; the watcher re-normalizes for - // itself whenever vault.json changes, so both agree on the folder layout. - if settings, err := v.GetSettings(); err == nil { - w.SetFolderPaths(settings.SystemFolderPaths) - } - - dist, err := web.Dist() - if err != nil { - log.Printf("warning: embedded web bundle not available: %v", err) - dist = nil - } - - srv := httpserver.New(v, w, dist, cfg) - httpSrv := &http.Server{ - Addr: cfg.Bind, - Handler: srv.Router(), - ReadTimeout: 0, // Websocket-friendly. - WriteTimeout: 0, - } - - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer cancel() - - go func() { - log.Printf("listening on http://%s", cfg.Bind) - if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("http serve: %v", err) - } - }() - - if !bindIsLoopback(cfg.Bind) && !cfg.BehindTLS { - go warnInsecureExposureLoop(ctx) - } - - <-ctx.Done() - log.Printf("shutting down…") - - shutdownCtx, stopShutdown := context.WithTimeout(context.Background(), 5*time.Second) - defer stopShutdown() - _ = httpSrv.Shutdown(shutdownCtx) -} - -// logVaultPermissionHelp turns the cryptic "mkdir … permission denied" into -// actionable guidance: the container runs as a non-root UID, so a bind-mounted -// host vault has to be writable by that UID (#227). -func logVaultPermissionHelp(vaultPath string, err error) { - log.Printf("vault init: %v", err) - log.Printf("→ ZenNotes runs as UID %d:%d and cannot write to the vault directory %q.", os.Getuid(), os.Getgid(), vaultPath) - log.Printf("→ The mounted host directory must be writable by that UID. Either:") - log.Printf("→ • run the container as a user that owns it: docker run --user \"$(id -u):$(id -g)\" …") - log.Printf("→ • or make the host directory owned by / writable for UID %d (e.g. chown).", os.Getuid()) - log.Printf("→ See https://github.com/ZenNotes/zennotes/blob/main/docs/how-to/self-host-with-docker.md#permissions") -} - -func logStartupBanner(cfg config.Config) { - log.Printf("vault: %s", cfg.VaultPath) - log.Printf("bind: %s", cfg.Bind) - authMode := "ZENNOTES_AUTH_TOKEN required" - if strings.TrimSpace(cfg.AuthToken) == "" { - authMode = "OPEN (no auth token set — anyone reachable can read/write)" - } - log.Printf("auth: %s", authMode) - tlsMode := "behind TLS proxy (cookies marked Secure, HSTS sent)" - if !cfg.BehindTLS { - tlsMode = "plain HTTP (set ZENNOTES_BEHIND_TLS=1 once a TLS proxy is in front)" - } - log.Printf("tls: %s", tlsMode) - log.Printf("cors: %s", corsMode(cfg)) - if !bindIsLoopback(cfg.Bind) && !cfg.BehindTLS { - log.Printf("WARNING: bound to a non-loopback address without ZENNOTES_BEHIND_TLS=1.") - log.Printf("WARNING: put a TLS-terminating reverse proxy in front before exposing publicly.") - } -} - -// corsMode describes the effective cross-origin policy at a glance, so an -// operator can see what is allowed at startup instead of inferring it from -// rejection lines after a client fails. (#482) -func corsMode(cfg config.Config) string { - for _, origin := range cfg.AllowedOrigins { - if strings.TrimSpace(origin) == httpserver.AllowAllOrigins { - return "any origin (ZENNOTES_ALLOWED_ORIGINS=*; credentials withheld cross-origin)" - } - } - if len(cfg.AllowedOrigins) == 0 { - return "same-origin only (set ZENNOTES_ALLOWED_ORIGINS for browser or WebView clients)" - } - return fmt.Sprintf("same-origin plus %s", strings.Join(cfg.AllowedOrigins, ", ")) -} - -func warnInsecureExposureLoop(ctx context.Context) { - t := time.NewTicker(15 * time.Minute) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - log.Printf("WARNING: still serving plain HTTP on a non-loopback bind; configure a TLS proxy and set ZENNOTES_BEHIND_TLS=1") - } - } -} - -func bindIsLoopback(bind string) bool { - host, _, err := net.SplitHostPort(bind) - if err != nil { - host = bind - } - host = strings.Trim(host, "[]") - if host == "" { - return false - } - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} diff --git a/apps/server/cmd/zennotes-server/main_test.go b/apps/server/cmd/zennotes-server/main_test.go deleted file mode 100644 index 00c580dd..00000000 --- a/apps/server/cmd/zennotes-server/main_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -import "testing" - -func TestBindIsLoopbackTreatsEmptyHostAsNonLoopback(t *testing.T) { - cases := []struct { - bind string - want bool - }{ - {":7878", false}, - {"0.0.0.0:7878", false}, - {"[::]:7878", false}, - {"127.0.0.1:7878", true}, - {"[::1]:7878", true}, - {"localhost:7878", true}, - } - - for _, tc := range cases { - if got := bindIsLoopback(tc.bind); got != tc.want { - t.Fatalf("bindIsLoopback(%q) = %v, want %v", tc.bind, got, tc.want) - } - } -} diff --git a/apps/server/go.mod b/apps/server/go.mod deleted file mode 100644 index 84d0fbc4..00000000 --- a/apps/server/go.mod +++ /dev/null @@ -1,11 +0,0 @@ -module github.com/ZenNotes/zennotes/apps/server - -go 1.25 - -require ( - github.com/coder/websocket v1.8.12 - github.com/fsnotify/fsnotify v1.8.0 - github.com/go-chi/chi/v5 v5.2.5 -) - -require golang.org/x/sys v0.21.0 // indirect diff --git a/apps/server/go.sum b/apps/server/go.sum deleted file mode 100644 index 0d3d4169..00000000 --- a/apps/server/go.sum +++ /dev/null @@ -1,8 +0,0 @@ -github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go deleted file mode 100644 index b6e58b75..00000000 --- a/apps/server/internal/config/config.go +++ /dev/null @@ -1,277 +0,0 @@ -package config - -import ( - "encoding/json" - "io/fs" - "log" - "net" - "os" - "path/filepath" - "strconv" - "strings" -) - -const ( - defaultMaxAssetBytes int64 = 50 << 20 // 50 MiB - defaultMaxNoteBytes int64 = 10 << 20 // 10 MiB - defaultVaultFileMode = fs.FileMode(0o600) - defaultVaultDirMode = fs.FileMode(0o700) -) - -const ( - AuthTokenSourceNone = "" - AuthTokenSourceConfig = "config" - AuthTokenSourceEnv = "env" - AuthTokenSourceFile = "file" -) - -type Config struct { - VaultPath string `json:"vaultPath"` - DefaultVaultPath string `json:"-"` - BrowseRoots []string `json:"-"` - AllowedOrigins []string `json:"-"` - Bind string `json:"bind"` - BasePath string `json:"basePath"` - AuthToken string `json:"authToken"` - AuthTokenSource string `json:"-"` - AllowUnscopedBrowse bool `json:"-"` - AllowInsecureNoAuth bool `json:"-"` - DevMode bool `json:"-"` - // DisableWatcher turns off the inotify file watcher (ZENNOTES_DISABLE_WATCHER). - // Live updates stop; the vault is still fully served. Useful where inotify is - // restricted and can hang the process (e.g. unprivileged LXC). (#179) - DisableWatcher bool `json:"-"` - - // Limits and security knobs. - MaxAssetBytes int64 `json:"-"` - MaxNoteBytes int64 `json:"-"` - BehindTLS bool `json:"-"` - // PersistSessions saves browser sessions to /sessions.json so they - // survive a server restart. Opt-in via ZENNOTES_PERSIST_SESSIONS. - PersistSessions bool `json:"-"` - TrustedProxies []net.IPNet `json:"-"` - VaultFileMode fs.FileMode `json:"-"` - VaultDirMode fs.FileMode `json:"-"` -} - -func configFilePath() string { - if v := os.Getenv("ZENNOTES_CONFIG_PATH"); v != "" { - return v - } - if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, ".zennotes", "server.json") - } - return ".zennotes-server.json" -} - -// SessionsPath is where opt-in persisted browser sessions live — next to the -// host config file (e.g. /data/sessions.json alongside /data/server.json). -func SessionsPath() string { - return filepath.Join(filepath.Dir(configFilePath()), "sessions.json") -} - -func Load() Config { - cfg := Config{ - Bind: "127.0.0.1:7878", - MaxAssetBytes: defaultMaxAssetBytes, - MaxNoteBytes: defaultMaxNoteBytes, - VaultFileMode: defaultVaultFileMode, - VaultDirMode: defaultVaultDirMode, - } - if raw, err := os.ReadFile(configFilePath()); err == nil { - var stored Config - if json.Unmarshal(raw, &stored) == nil { - if stored.VaultPath != "" { - cfg.VaultPath = stored.VaultPath - } - if stored.Bind != "" { - cfg.Bind = stored.Bind - } - if stored.BasePath != "" { - cfg.BasePath = stored.BasePath - } - if stored.AuthToken != "" { - cfg.AuthToken = stored.AuthToken - cfg.AuthTokenSource = AuthTokenSourceConfig - } - } - } - if v := os.Getenv("ZENNOTES_VAULT_PATH"); v != "" { - cfg.VaultPath = v - } - if v := os.Getenv("ZENNOTES_DEFAULT_VAULT_PATH"); v != "" { - cfg.DefaultVaultPath = v - } - cfg.BrowseRoots = parseListEnv("ZENNOTES_BROWSE_ROOTS") - cfg.AllowedOrigins = parseListEnv("ZENNOTES_ALLOWED_ORIGINS") - if v := os.Getenv("ZENNOTES_BIND"); v != "" { - cfg.Bind = v - } - if v := os.Getenv("ZENNOTES_BASE_PATH"); v != "" { - cfg.BasePath = v - } - cfg.BasePath = NormalizeBasePath(cfg.BasePath) - if v := os.Getenv("ZENNOTES_AUTH_TOKEN"); v != "" { - cfg.AuthToken = v - cfg.AuthTokenSource = AuthTokenSourceEnv - } else if path := os.Getenv("ZENNOTES_AUTH_TOKEN_FILE"); path != "" { - // The token comes from a file (the Docker/Kubernetes "*_FILE" secrets - // convention). A set-but-unreadable or empty file is a misconfiguration - // the user meant to work — surface it clearly instead of silently - // falling through to the generic "missing ZENNOTES_AUTH_TOKEN" error. - if raw, err := os.ReadFile(path); err != nil { - log.Printf("config: ZENNOTES_AUTH_TOKEN_FILE is set to %q but it could not be read: %v", path, err) - } else if token := strings.TrimSpace(string(raw)); token == "" { - log.Printf("config: ZENNOTES_AUTH_TOKEN_FILE %q is empty — no auth token loaded", path) - } else { - cfg.AuthToken = token - cfg.AuthTokenSource = AuthTokenSourceFile - } - } - cfg.AllowUnscopedBrowse = envEnabled("ZENNOTES_ALLOW_UNSCOPED_BROWSE") - cfg.AllowInsecureNoAuth = envEnabled("ZENNOTES_ALLOW_INSECURE_NOAUTH") - cfg.DevMode = envEnabled("ZENNOTES_DEV") - cfg.DisableWatcher = envEnabled("ZENNOTES_DISABLE_WATCHER") - cfg.BehindTLS = envEnabled("ZENNOTES_BEHIND_TLS") - cfg.PersistSessions = envEnabled("ZENNOTES_PERSIST_SESSIONS") - cfg.TrustedProxies = parseCIDRListEnv("ZENNOTES_TRUSTED_PROXIES") - if v := parseInt64Env("ZENNOTES_MAX_ASSET_BYTES"); v > 0 { - cfg.MaxAssetBytes = v - } - if v := parseInt64Env("ZENNOTES_MAX_NOTE_BYTES"); v > 0 { - cfg.MaxNoteBytes = v - } - if m, ok := parseFileModeEnv("ZENNOTES_VAULT_FILE_MODE"); ok { - cfg.VaultFileMode = m - } - if m, ok := parseFileModeEnv("ZENNOTES_VAULT_DIR_MODE"); ok { - cfg.VaultDirMode = m - } - if cfg.VaultPath == "" { - if cfg.DefaultVaultPath != "" { - cfg.VaultPath = cfg.DefaultVaultPath - } else { - if home, err := os.UserHomeDir(); err == nil { - cfg.VaultPath = filepath.Join(home, "ZenNotesVault") - } else { - cfg.VaultPath = "./vault" - } - } - } - return cfg -} - -func SaveHost(cfg Config) error { - target := configFilePath() - if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { - return err - } - out, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - return os.WriteFile(target, out, 0o600) -} - -func LegacyVaultConfigPath(vaultRoot string) string { - return filepath.Join(vaultRoot, ".zennotes", "server.json") -} - -func LegacyVaultConfigExists(vaultRoot string) bool { - _, err := os.Stat(LegacyVaultConfigPath(vaultRoot)) - return err == nil -} - -// NormalizeBasePath coerces a raw base-path string into the form the -// server uses everywhere: empty (meaning "serve at root") or a path that -// starts with `/` and has no trailing slash, e.g. "/zennotes". Multiple -// adjacent slashes are collapsed. -func NormalizeBasePath(raw string) string { - trimmed := strings.TrimSpace(raw) - if trimmed == "" || trimmed == "/" { - return "" - } - if !strings.HasPrefix(trimmed, "/") { - trimmed = "/" + trimmed - } - // Collapse repeated slashes ("/foo//bar" → "/foo/bar"). - for strings.Contains(trimmed, "//") { - trimmed = strings.ReplaceAll(trimmed, "//", "/") - } - trimmed = strings.TrimRight(trimmed, "/") - if trimmed == "" { - return "" - } - return trimmed -} - -func parseListEnv(name string) []string { - raw := os.Getenv(name) - if raw == "" { - return nil - } - parts := strings.Split(raw, ",") - values := make([]string, 0, len(parts)) - for _, part := range parts { - if trimmed := strings.TrimSpace(part); trimmed != "" { - values = append(values, trimmed) - } - } - return values -} - -func parseCIDRListEnv(name string) []net.IPNet { - parts := parseListEnv(name) - if len(parts) == 0 { - return nil - } - out := make([]net.IPNet, 0, len(parts)) - for _, p := range parts { - if !strings.Contains(p, "/") { - if ip := net.ParseIP(p); ip != nil { - bits := 32 - if ip.To4() == nil { - bits = 128 - } - out = append(out, net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) - continue - } - } - if _, n, err := net.ParseCIDR(p); err == nil { - out = append(out, *n) - } - } - return out -} - -func parseInt64Env(name string) int64 { - raw := strings.TrimSpace(os.Getenv(name)) - if raw == "" { - return 0 - } - v, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return 0 - } - return v -} - -func parseFileModeEnv(name string) (fs.FileMode, bool) { - raw := strings.TrimSpace(os.Getenv(name)) - if raw == "" { - return 0, false - } - if !strings.HasPrefix(raw, "0") { - raw = "0" + raw - } - v, err := strconv.ParseUint(raw, 8, 32) - if err != nil { - return 0, false - } - return fs.FileMode(v), true -} - -func envEnabled(name string) bool { - raw := strings.TrimSpace(strings.ToLower(os.Getenv(name))) - return raw == "1" || raw == "true" || raw == "yes" || raw == "on" -} diff --git a/apps/server/internal/config/config_test.go b/apps/server/internal/config/config_test.go deleted file mode 100644 index 6067ab50..00000000 --- a/apps/server/internal/config/config_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package config - -import ( - "io/fs" - "os" - "path/filepath" - "testing" -) - -func TestLoadAuthTokenFromFile(t *testing.T) { - tokenFile := filepath.Join(t.TempDir(), "token") - const want = "from-file-token-xxxxxx" - if err := os.WriteFile(tokenFile, []byte(" "+want+"\n"), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("ZENNOTES_AUTH_TOKEN", "") - t.Setenv("ZENNOTES_AUTH_TOKEN_FILE", tokenFile) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - - cfg := Load() - if cfg.AuthToken != want { - t.Fatalf("AuthToken = %q, want %q (whitespace must be trimmed)", cfg.AuthToken, want) - } - if cfg.AuthTokenSource != AuthTokenSourceFile { - t.Fatalf("AuthTokenSource = %q, want %q", cfg.AuthTokenSource, AuthTokenSourceFile) - } -} - -func TestEnvAuthTokenWinsOverFile(t *testing.T) { - tokenFile := filepath.Join(t.TempDir(), "token") - if err := os.WriteFile(tokenFile, []byte("from-file"), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("ZENNOTES_AUTH_TOKEN", "from-env") - t.Setenv("ZENNOTES_AUTH_TOKEN_FILE", tokenFile) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - - cfg := Load() - if cfg.AuthToken != "from-env" { - t.Fatalf("AuthToken = %q, want from-env", cfg.AuthToken) - } - if cfg.AuthTokenSource != AuthTokenSourceEnv { - t.Fatalf("AuthTokenSource = %q, want %q", cfg.AuthTokenSource, AuthTokenSourceEnv) - } -} - -// #304: a ZENNOTES_AUTH_TOKEN_FILE pointing at a missing/unreadable path must -// not set a token (and must not panic); the read error is logged so the failure -// is visible rather than surfacing as a misleading "missing token" error. -func TestAuthTokenFileMissingIsIgnoredNotFatal(t *testing.T) { - t.Setenv("ZENNOTES_AUTH_TOKEN", "") - t.Setenv("ZENNOTES_AUTH_TOKEN_FILE", filepath.Join(t.TempDir(), "does-not-exist")) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - - cfg := Load() - if cfg.AuthToken != "" { - t.Fatalf("AuthToken = %q, want empty for an unreadable file", cfg.AuthToken) - } - if cfg.AuthTokenSource != AuthTokenSourceNone { - t.Fatalf("AuthTokenSource = %q, want %q", cfg.AuthTokenSource, AuthTokenSourceNone) - } -} - -// An empty (or whitespace-only) token file loads no token rather than an empty one. -func TestAuthTokenFileEmptyLoadsNoToken(t *testing.T) { - tokenFile := filepath.Join(t.TempDir(), "token") - if err := os.WriteFile(tokenFile, []byte(" \n"), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("ZENNOTES_AUTH_TOKEN", "") - t.Setenv("ZENNOTES_AUTH_TOKEN_FILE", tokenFile) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - - cfg := Load() - if cfg.AuthToken != "" { - t.Fatalf("AuthToken = %q, want empty for a whitespace-only file", cfg.AuthToken) - } - if cfg.AuthTokenSource != AuthTokenSourceNone { - t.Fatalf("AuthTokenSource = %q, want %q", cfg.AuthTokenSource, AuthTokenSourceNone) - } -} - -func TestLoadDefaultLimitsAndModes(t *testing.T) { - t.Setenv("ZENNOTES_AUTH_TOKEN", "") - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - cfg := Load() - if cfg.MaxAssetBytes != defaultMaxAssetBytes { - t.Errorf("MaxAssetBytes default = %d, want %d", cfg.MaxAssetBytes, defaultMaxAssetBytes) - } - if cfg.MaxNoteBytes != defaultMaxNoteBytes { - t.Errorf("MaxNoteBytes default = %d, want %d", cfg.MaxNoteBytes, defaultMaxNoteBytes) - } - if cfg.VaultFileMode != defaultVaultFileMode { - t.Errorf("VaultFileMode default = %v, want %v", cfg.VaultFileMode, defaultVaultFileMode) - } - if cfg.VaultDirMode != defaultVaultDirMode { - t.Errorf("VaultDirMode default = %v, want %v", cfg.VaultDirMode, defaultVaultDirMode) - } -} - -func TestParseCIDRListEnv(t *testing.T) { - t.Setenv("X", "127.0.0.1/32, 10.0.0.0/8 ,bad,192.168.1.5") - got := parseCIDRListEnv("X") - if len(got) != 3 { - t.Fatalf("expected 3 valid entries, got %d: %+v", len(got), got) - } - // 192.168.1.5 (bare) should be expanded to /32. - last := got[2] - ones, bits := last.Mask.Size() - if ones != 32 || bits != 32 { - t.Fatalf("bare IP should be /32, got /%d (bits=%d)", ones, bits) - } - if !last.Contains(last.IP) { - t.Fatalf("Net should contain its own IP") - } -} - -func TestNormalizeBasePath(t *testing.T) { - cases := []struct { - raw string - want string - }{ - {"", ""}, - {" ", ""}, - {"/", ""}, - {"//", ""}, - {"zennotes", "/zennotes"}, - {"/zennotes", "/zennotes"}, - {"/zennotes/", "/zennotes"}, - {"/zennotes//", "/zennotes"}, - {"/foo/bar", "/foo/bar"}, - {"/foo//bar/", "/foo/bar"}, - {" /apps/notes ", "/apps/notes"}, - } - for _, c := range cases { - if got := NormalizeBasePath(c.raw); got != c.want { - t.Errorf("NormalizeBasePath(%q) = %q, want %q", c.raw, got, c.want) - } - } -} - -func TestLoadBasePathFromEnv(t *testing.T) { - t.Setenv("ZENNOTES_AUTH_TOKEN", "") - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "missing.json")) - t.Setenv("ZENNOTES_BASE_PATH", "/zennotes/") - cfg := Load() - if cfg.BasePath != "/zennotes" { - t.Fatalf("BasePath = %q, want /zennotes (trailing slash trimmed)", cfg.BasePath) - } -} - -func TestParseFileModeEnv(t *testing.T) { - cases := []struct { - raw string - want fs.FileMode - ok bool - }{ - {"", 0, false}, - {"600", 0o600, true}, - {"0600", 0o600, true}, - {"0o600", 0, false}, // not octal-prefix syntax - {"755", 0o755, true}, - {"abc", 0, false}, - } - for _, c := range cases { - t.Setenv("X", c.raw) - got, ok := parseFileModeEnv("X") - if ok != c.ok || got != c.want { - t.Errorf("parseFileModeEnv(%q) = (%v, %v), want (%v, %v)", c.raw, got, ok, c.want, c.ok) - } - } -} - -// #sessions: ZENNOTES_PERSIST_SESSIONS is opt-in (off by default), and the -// sessions file sits next to the host config. -func TestPersistSessionsFlagAndPath(t *testing.T) { - cfgPath := filepath.Join(t.TempDir(), "server.json") - t.Setenv("ZENNOTES_AUTH_TOKEN", "x") - t.Setenv("ZENNOTES_CONFIG_PATH", cfgPath) - - t.Setenv("ZENNOTES_PERSIST_SESSIONS", "") - if Load().PersistSessions { - t.Fatal("PersistSessions should default off") - } - t.Setenv("ZENNOTES_PERSIST_SESSIONS", "1") - if !Load().PersistSessions { - t.Fatal("ZENNOTES_PERSIST_SESSIONS=1 should enable it") - } - if got, want := SessionsPath(), filepath.Join(filepath.Dir(cfgPath), "sessions.json"); got != want { - t.Fatalf("SessionsPath = %q, want %q", got, want) - } -} diff --git a/apps/server/internal/httpserver/asset_ops_test.go b/apps/server/internal/httpserver/asset_ops_test.go deleted file mode 100644 index 18631a5b..00000000 --- a/apps/server/internal/httpserver/asset_ops_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package httpserver - -import ( - "bytes" - "encoding/json" - "net/http" - "os" - "path/filepath" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -// TestAssetRenameAndMoveEndpoints exercises the full HTTP wiring for the asset -// mutation endpoints added for remote vaults (#379): rename in place, then move -// into a folder, asserting the JSON field contract the web bridge relies on. -func TestAssetRenameAndMoveEndpoints(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "assets"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "assets", "pic.png"), []byte("PNG"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - postJSON := func(path string, payload map[string]string) (string, int) { - t.Helper() - body, _ := json.Marshal(payload) - resp, err := client.Post(server.URL+path, "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST %s: %v", path, err) - } - defer resp.Body.Close() - var meta struct { - Path string `json:"path"` - } - if resp.StatusCode == http.StatusOK { - if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { - t.Fatalf("decode %s response: %v", path, err) - } - } - return meta.Path, resp.StatusCode - } - - gotPath, status := postJSON("/api/assets/rename", map[string]string{"path": "assets/pic.png", "name": "shot.png"}) - if status != http.StatusOK { - t.Fatalf("rename status = %d, want 200", status) - } - if gotPath != "assets/shot.png" { - t.Fatalf("rename path = %q, want assets/shot.png", gotPath) - } - - gotPath, status = postJSON("/api/assets/move", map[string]string{"path": "assets/shot.png", "targetDir": "media"}) - if status != http.StatusOK { - t.Fatalf("move status = %d, want 200", status) - } - if gotPath != "media/shot.png" { - t.Fatalf("move path = %q, want media/shot.png", gotPath) - } - if _, err := os.Stat(filepath.Join(root, "media", "shot.png")); err != nil { - t.Errorf("moved file missing on disk: %v", err) - } -} - -// TestFolderColorsPersistOverHTTP is the reporter's exact scenario (#379): a -// recolor saved from the web client must survive the /vault/settings round-trip -// instead of being silently dropped by the server. -func TestFolderColorsPersistOverHTTP(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - payload := map[string]any{ - "primaryNotesLocation": "inbox", - "folderColors": map[string]string{"inbox:Projects": "violet"}, - } - body, _ := json.Marshal(payload) - resp, err := client.Post(server.URL+"/api/vault/settings", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST /api/vault/settings: %v", err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("set settings status = %d, want 200", resp.StatusCode) - } - - getResp, err := client.Get(server.URL + "/api/vault/settings") - if err != nil { - t.Fatalf("GET /api/vault/settings: %v", err) - } - defer getResp.Body.Close() - var got struct { - FolderColors map[string]string `json:"folderColors"` - } - if err := json.NewDecoder(getResp.Body).Decode(&got); err != nil { - t.Fatalf("decode settings: %v", err) - } - if got.FolderColors["inbox:Projects"] != "violet" { - t.Fatalf("folderColors dropped over HTTP round-trip: %v", got.FolderColors) - } -} - -// TestAssetDeleteRestorePurgeEndpoints exercises the deleted-assets store over -// HTTP: delete parks the file with an undo token, the list surfaces it, restore -// brings it back deduped, and purge/empty leave the store clean. This is the -// contract the desktop remote workspace and the web bridge share. -func TestAssetDeleteRestorePurgeEndpoints(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "assets"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "assets", "pic.png"), []byte("PNG"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - post := func(path string, payload any) *http.Response { - t.Helper() - body, _ := json.Marshal(payload) - resp, err := client.Post(server.URL+path, "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST %s: %v", path, err) - } - return resp - } - - resp := post("/api/assets/delete", map[string]string{"path": "assets/pic.png"}) - if resp.StatusCode != http.StatusOK { - t.Fatalf("delete status = %d, want 200", resp.StatusCode) - } - var deleted struct { - Path string `json:"path"` - Name string `json:"name"` - UndoToken string `json:"undoToken"` - DeletedAt string `json:"deletedAt"` - } - if err := json.NewDecoder(resp.Body).Decode(&deleted); err != nil { - t.Fatalf("decode delete response: %v", err) - } - resp.Body.Close() - if deleted.Path != "assets/pic.png" || deleted.UndoToken == "" || deleted.DeletedAt == "" { - t.Fatalf("delete response = %+v, want path+token+timestamp", deleted) - } - if _, err := os.Stat(filepath.Join(root, "assets", "pic.png")); !os.IsNotExist(err) { - t.Fatal("file still present after HTTP delete") - } - - listResp, err := client.Get(server.URL + "/api/assets/deleted") - if err != nil { - t.Fatal(err) - } - var listed []struct { - UndoToken string `json:"undoToken"` - } - if err := json.NewDecoder(listResp.Body).Decode(&listed); err != nil { - t.Fatalf("decode deleted list: %v", err) - } - listResp.Body.Close() - if len(listed) != 1 || listed[0].UndoToken != deleted.UndoToken { - t.Fatalf("deleted list = %+v, want the parked entry", listed) - } - - resp = post("/api/assets/restore", deleted) - if resp.StatusCode != http.StatusOK { - t.Fatalf("restore status = %d, want 200", resp.StatusCode) - } - var restored struct { - Path string `json:"path"` - } - if err := json.NewDecoder(resp.Body).Decode(&restored); err != nil { - t.Fatalf("decode restore response: %v", err) - } - resp.Body.Close() - if restored.Path != "assets/pic.png" { - t.Fatalf("restored path = %q, want assets/pic.png", restored.Path) - } - if _, err := os.Stat(filepath.Join(root, "assets", "pic.png")); err != nil { - t.Fatalf("restored file missing: %v", err) - } - - // Round two: delete again, then purge instead of restore. - resp = post("/api/assets/delete", map[string]string{"path": "assets/pic.png"}) - if err := json.NewDecoder(resp.Body).Decode(&deleted); err != nil { - t.Fatalf("decode second delete: %v", err) - } - resp.Body.Close() - resp = post("/api/assets/purge", map[string]string{"undoToken": deleted.UndoToken}) - if resp.StatusCode != http.StatusNoContent { - t.Fatalf("purge status = %d, want 204", resp.StatusCode) - } - resp.Body.Close() - resp = post("/api/assets/empty-deleted", nil) - if resp.StatusCode != http.StatusNoContent { - t.Fatalf("empty status = %d, want 204", resp.StatusCode) - } - resp.Body.Close() -} - -// TestAssetDuplicateEndpoint checks the copy lands next to the source with the -// shared " copy" naming. -func TestAssetDuplicateEndpoint(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "assets"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "assets", "pic.png"), []byte("PNG"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - body, _ := json.Marshal(map[string]string{"path": "assets/pic.png"}) - resp, err := client.Post(server.URL+"/api/assets/duplicate", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("duplicate status = %d, want 200", resp.StatusCode) - } - var meta struct { - Path string `json:"path"` - } - if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { - t.Fatal(err) - } - if meta.Path != "assets/pic copy.png" { - t.Fatalf("duplicate path = %q, want assets/pic copy.png", meta.Path) - } -} diff --git a/apps/server/internal/httpserver/basepath_test.go b/apps/server/internal/httpserver/basepath_test.go deleted file mode 100644 index 60c849ee..00000000 --- a/apps/server/internal/httpserver/basepath_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package httpserver - -import ( - "bytes" - "io" - "io/fs" - "net/http" - "net/http/httptest" - "strings" - "testing" - "testing/fstest" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" -) - -func newBasePathServer(t *testing.T, basePath string) *httptest.Server { - t.Helper() - cfg := config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:0", - BasePath: basePath, - AllowInsecureNoAuth: true, - } - v, err := vault.New(cfg.VaultPath, vault.Options{}) - if err != nil { - t.Fatalf("vault.New: %v", err) - } - static := fstest.MapFS{ - "index.html": &fstest.MapFile{ - Data: []byte("ZenNotes"), - }, - "manifest.webmanifest": &fstest.MapFile{Data: []byte("{}")}, - "assets/index-test.css": &fstest.MapFile{Data: []byte("body{color:red}")}, - } - srv := httptest.NewServer(New(v, nil, fs.FS(static), cfg).Router()) - t.Cleanup(srv.Close) - return srv -} - -func TestBasePathHealthz(t *testing.T) { - srv := newBasePathServer(t, "/zennotes") - resp, err := http.Get(srv.URL + "/zennotes/api/healthz") - if err != nil { - t.Fatalf("get under base: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status under base: %d", resp.StatusCode) - } - - off, err := http.Get(srv.URL + "/api/healthz") - if err != nil { - t.Fatalf("get without base: %v", err) - } - defer off.Body.Close() - if off.StatusCode == http.StatusOK { - t.Fatalf("requests outside the base path should not match: got 200") - } -} - -func TestBasePathServesStaticAssets(t *testing.T) { - srv := newBasePathServer(t, "/zennotes") - - // A hashed CSS asset under the base path must serve the real file - // with a CSS content type. If the prefix isn't stripped before the - // embedded-FS lookup, serveStatic falls back to index.html and the - // browser refuses it for a bad MIME type (issue #58). - resp, err := http.Get(srv.URL + "/zennotes/assets/index-test.css") - if err != nil { - t.Fatalf("get css under base: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status: %d", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/css") { - t.Fatalf("expected text/css content type, got %q", ct) - } - body, _ := io.ReadAll(resp.Body) - if string(body) != "body{color:red}" { - t.Fatalf("expected css body, got %q", string(body)) - } -} - -func TestBasePathServesManifest(t *testing.T) { - srv := newBasePathServer(t, "/zennotes") - resp, err := http.Get(srv.URL + "/zennotes/manifest.webmanifest") - if err != nil { - t.Fatalf("get manifest under base: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status: %d", resp.StatusCode) - } - body, _ := io.ReadAll(resp.Body) - if string(body) != "{}" { - t.Fatalf("expected manifest body {}, got %q", string(body)) - } -} - -func TestBasePathInjectsRuntimeHint(t *testing.T) { - srv := newBasePathServer(t, "/zennotes") - resp, err := http.Get(srv.URL + "/zennotes/") - if err != nil { - t.Fatalf("get root: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status: %d", resp.StatusCode) - } - body := make([]byte, 4096) - n, _ := resp.Body.Read(body) - if !bytes.Contains(body[:n], []byte(``)) { - t.Fatalf("expected base path meta tag in index.html, got:\n%s", string(body[:n])) - } -} - -func TestRootDeploymentHasNoBasePathHint(t *testing.T) { - srv := newBasePathServer(t, "") - resp, err := http.Get(srv.URL + "/") - if err != nil { - t.Fatalf("get root: %v", err) - } - defer resp.Body.Close() - body := make([]byte, 4096) - n, _ := resp.Body.Read(body) - if bytes.Contains(body[:n], []byte("zn-base-path")) { - t.Fatalf("root deployment should not inject base path meta, got:\n%s", string(body[:n])) - } -} diff --git a/apps/server/internal/httpserver/browse_roots.go b/apps/server/internal/httpserver/browse_roots.go deleted file mode 100644 index f0a7381c..00000000 --- a/apps/server/internal/httpserver/browse_roots.go +++ /dev/null @@ -1,131 +0,0 @@ -package httpserver - -import ( - "net/http" - "os" - "path/filepath" - "runtime" - "slices" - "strings" -) - -func existingDir(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() -} - -func resolveExistingDir(path string) (string, error) { - if !filepath.IsAbs(path) { - abs, err := filepath.Abs(path) - if err != nil { - return "", err - } - path = abs - } - resolved, err := filepath.EvalSymlinks(filepath.Clean(path)) - if err != nil { - return "", err - } - info, err := os.Stat(resolved) - if err != nil { - return "", err - } - if !info.IsDir() { - return "", httpStatusError{code: http.StatusBadRequest, msg: "path is not a directory"} - } - return resolved, nil -} - -func pathWithinRoot(target string, root string) bool { - cleanTarget := filepath.Clean(target) - cleanRoot := filepath.Clean(root) - return cleanTarget == cleanRoot || strings.HasPrefix(cleanTarget, cleanRoot+string(filepath.Separator)) -} - -func (s *Server) effectiveBrowseRoots() []string { - cfg := s.currentConfig() - if cfg.AllowUnscopedBrowse { - return nil - } - candidates := cfg.BrowseRoots - if len(candidates) == 0 { - if current := s.currentVault(); current != nil { - candidates = append(candidates, current.Root()) - } - } - if len(candidates) == 0 && strings.TrimSpace(cfg.DefaultVaultPath) != "" { - candidates = append(candidates, cfg.DefaultVaultPath) - } - if len(candidates) == 0 && strings.TrimSpace(cfg.VaultPath) != "" { - candidates = append(candidates, cfg.VaultPath) - } - roots := make([]string, 0, len(candidates)) - for _, candidate := range candidates { - if resolved, err := resolveExistingDir(candidate); err == nil { - if !slices.Contains(roots, resolved) { - roots = append(roots, resolved) - } - } - } - return roots -} - -func (s *Server) ensureBrowsePathAllowed(path string) (string, error) { - resolved, err := resolveExistingDir(path) - if err != nil { - return "", err - } - roots := s.effectiveBrowseRoots() - if len(roots) == 0 { - return resolved, nil - } - for _, root := range roots { - if pathWithinRoot(resolved, root) { - return resolved, nil - } - } - return "", httpStatusError{code: http.StatusForbidden, msg: "path is outside the allowed browse roots"} -} - -func (s *Server) defaultBrowsePath() string { - roots := s.effectiveBrowseRoots() - if len(roots) > 0 { - return roots[0] - } - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { - return home - } - if runtime.GOOS == "windows" { - return `C:\` - } - return string(filepath.Separator) -} - -func (s *Server) browseShortcuts() []directoryBrowseShortcut { - shortcuts := make([]directoryBrowseShortcut, 0, 8) - roots := s.effectiveBrowseRoots() - for idx, rootPath := range roots { - shortcuts = appendBrowseShortcut(shortcuts, browseRootLabel(rootPath, idx), rootPath) - } - if current := s.currentVault(); current != nil { - shortcuts = appendBrowseShortcut(shortcuts, "Current Vault", current.Root()) - } - if len(roots) == 0 { - root := filesystemRootForPath(s.defaultBrowsePath()) - shortcuts = appendBrowseShortcut(shortcuts, "Root", root) - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { - shortcuts = appendBrowseShortcut(shortcuts, "Home", home) - shortcuts = appendBrowseShortcut(shortcuts, "Desktop", filepath.Join(home, "Desktop")) - shortcuts = appendBrowseShortcut(shortcuts, "Documents", filepath.Join(home, "Documents")) - shortcuts = appendBrowseShortcut(shortcuts, "Downloads", filepath.Join(home, "Downloads")) - if runtime.GOOS == "darwin" { - shortcuts = appendBrowseShortcut( - shortcuts, - "iCloud Drive", - filepath.Join(home, "Library", "Mobile Documents", "com~apple~CloudDocs"), - ) - } - } - } - return shortcuts -} diff --git a/apps/server/internal/httpserver/cors_test.go b/apps/server/internal/httpserver/cors_test.go deleted file mode 100644 index 6c9e7f3e..00000000 --- a/apps/server/internal/httpserver/cors_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package httpserver - -import ( - "net/http" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -// corsHeaders returns what the middleware answers for a given Origin. -func corsHeaders(t *testing.T, cfg config.Config, origin string) http.Header { - t.Helper() - root := t.TempDir() - cfg.VaultPath = root - cfg.DefaultVaultPath = root - if cfg.Bind == "" { - // A non-loopback bind, so the loopback exemption never masks the - // behaviour under test. - cfg.Bind = "192.0.2.10:7878" - } - server, _ := newTestServer(t, cfg) - - req, err := http.NewRequest(http.MethodOptions, server.URL+"/api/capabilities", nil) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("Origin", origin) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("preflight for %q: %v", origin, err) - } - t.Cleanup(func() { resp.Body.Close() }) - return resp.Header -} - -func TestNormalizeOrigin(t *testing.T) { - cases := []struct { - raw string - want string - }{ - // Origins a browser or WebView actually sends. - {"https://notes.example.com", "https://notes.example.com"}, - {"HTTPS://Notes.Example.COM", "https://notes.example.com"}, - {"http://localhost:5173", "http://localhost:5173"}, - {"app://.", "app://."}, - {"capacitor://localhost", "capacitor://localhost"}, - // Opaque and scheme-only origins were dropped before #482, so an - // operator could list them and still be rejected. - {"null", "null"}, - {"NULL", "null"}, - {"file://", "file://"}, - {"*", "*"}, - // Still not origins. - {"", ""}, - {" ", ""}, - {"notes.example.com", ""}, - {"mailto:someone@example.com", ""}, - } - for _, tc := range cases { - if got := normalizeOrigin(tc.raw); got != tc.want { - t.Errorf("normalizeOrigin(%q) = %q, want %q", tc.raw, got, tc.want) - } - } -} - -func TestCORSAllowsExplicitlyListedOpaqueOrigins(t *testing.T) { - // Listing these verbatim is what the docs and operators do; before #482 - // they were parsed away and rejected anyway. - for _, origin := range []string{"null", "file://", "app://."} { - cfg := config.Config{AuthToken: "secret-token", AllowedOrigins: []string{"null", "file://", "app://."}} - headers := corsHeaders(t, cfg, origin) - if got := headers.Get("Access-Control-Allow-Origin"); got != origin { - t.Errorf("origin %q: Allow-Origin = %q, want %q", origin, got, origin) - } - if got := headers.Get("Access-Control-Allow-Credentials"); got != "true" { - t.Errorf("origin %q: an explicitly listed origin keeps credentials, got %q", origin, got) - } - } -} - -func TestCORSWildcardAllowsAnyOriginWithoutCredentials(t *testing.T) { - cfg := config.Config{AuthToken: "secret-token", AllowedOrigins: []string{"*"}} - for _, origin := range []string{"https://anything.example", "null", "file://", "app://."} { - headers := corsHeaders(t, cfg, origin) - if got := headers.Get("Access-Control-Allow-Origin"); got != origin { - t.Errorf("wildcard: Allow-Origin for %q = %q, want the origin echoed", origin, got) - } - // Echoing any origin *and* allowing credentials would let any site a - // user visits drive their session cookie. - if got := headers.Get("Access-Control-Allow-Credentials"); got != "" { - t.Errorf("wildcard: credentials must be withheld for %q, got %q", origin, got) - } - } -} - -func TestCORSRejectsUnlistedOriginByDefault(t *testing.T) { - cfg := config.Config{AuthToken: "secret-token"} - headers := corsHeaders(t, cfg, "https://evil.example") - if got := headers.Get("Access-Control-Allow-Origin"); got != "" { - t.Errorf("unlisted origin must not be echoed, got %q", got) - } -} - -func TestCORSAllowsSameOriginWithCredentials(t *testing.T) { - // The server's own web bundle: same origin as the request, always allowed. - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "192.0.2.10:7878", - AuthToken: "secret-token", - }) - req, err := http.NewRequest(http.MethodOptions, server.URL+"/api/capabilities", nil) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("Origin", "http://"+req.Host) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("preflight: %v", err) - } - defer resp.Body.Close() - if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" { - t.Errorf("same-origin keeps credentials, got %q", got) - } -} - -func TestCORSPreflightShortCircuits(t *testing.T) { - cfg := config.Config{AuthToken: "secret-token", AllowedOrigins: []string{"https://notes.example.com"}} - root := t.TempDir() - cfg.VaultPath = root - cfg.DefaultVaultPath = root - cfg.Bind = "192.0.2.10:7878" - server, _ := newTestServer(t, cfg) - - req, err := http.NewRequest(http.MethodOptions, server.URL+"/api/capabilities", nil) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("Origin", "https://notes.example.com") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("preflight: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - t.Errorf("preflight status = %d, want %d", resp.StatusCode, http.StatusNoContent) - } -} diff --git a/apps/server/internal/httpserver/csp_test.go b/apps/server/internal/httpserver/csp_test.go deleted file mode 100644 index 3815041a..00000000 --- a/apps/server/internal/httpserver/csp_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package httpserver - -import ( - "strings" - "testing" -) - -// The web client shows a vault's PDFs in an iframe served by this same -// server, and the PDF response carries the policy too. `frame-ancestors -// 'none'` therefore forbade the app's own same-origin frame and every PDF -// embed rendered as a blocked frame (#121). Same-origin framing must stay -// allowed; framing by other sites stays blocked. -func TestContentSecurityPolicyAllowsSameOriginFraming(t *testing.T) { - csp := contentSecurityPolicy() - if !strings.Contains(csp, "frame-ancestors 'self'") { - t.Fatalf("CSP must allow same-origin framing for PDF embeds, got: %s", csp) - } - if strings.Contains(csp, "frame-ancestors 'none'") { - t.Fatalf("CSP still forbids same-origin framing: %s", csp) - } -} diff --git a/apps/server/internal/httpserver/excalidraw_test.go b/apps/server/internal/httpserver/excalidraw_test.go deleted file mode 100644 index cf2d8ca9..00000000 --- a/apps/server/internal/httpserver/excalidraw_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package httpserver - -import ( - "bytes" - "encoding/json" - "net/http" - "strings" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -// TestCreateExcalidrawEndpoint exercises the full HTTP wiring: log in, POST -// /api/excalidraw/create, and confirm the drawing comes back as a `.excalidraw` -// note that then shows up in /api/notes (and not in /api/assets). -func TestCreateExcalidrawEndpoint(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - body, _ := json.Marshal(map[string]string{"folder": "inbox", "title": "My Sketch"}) - resp, err := client.Post(server.URL+"/api/excalidraw/create", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST /api/excalidraw/create: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("create status: %d", resp.StatusCode) - } - var created struct { - Path string `json:"path"` - Title string `json:"title"` - } - if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { - t.Fatalf("decode create response: %v", err) - } - if !strings.HasSuffix(created.Path, ".excalidraw") { - t.Fatalf("created path = %q, want a .excalidraw file", created.Path) - } - if created.Title != "My Sketch" { - t.Errorf("created title = %q, want My Sketch", created.Title) - } - - listResp, err := client.Get(server.URL + "/api/notes") - if err != nil { - t.Fatalf("GET /api/notes: %v", err) - } - defer listResp.Body.Close() - var notes []struct { - Path string `json:"path"` - } - if err := json.NewDecoder(listResp.Body).Decode(¬es); err != nil { - t.Fatalf("decode notes: %v", err) - } - found := false - for _, n := range notes { - if n.Path == created.Path { - found = true - } - } - if !found { - t.Errorf("created drawing %q not returned by /api/notes", created.Path) - } -} diff --git a/apps/server/internal/httpserver/limiter_test.go b/apps/server/internal/httpserver/limiter_test.go deleted file mode 100644 index cfbaebb2..00000000 --- a/apps/server/internal/httpserver/limiter_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package httpserver - -import ( - "testing" - "time" -) - -func TestBackoffDelay(t *testing.T) { - cases := []struct { - failures int - want time.Duration - }{ - {0, 0}, - {1, 1 * time.Second}, - {2, 2 * time.Second}, - {3, 4 * time.Second}, - {4, 8 * time.Second}, - {5, 16 * time.Second}, - {6, 32 * time.Second}, - {7, 60 * time.Second}, // capped - {99, 60 * time.Second}, - } - for _, c := range cases { - if got := backoffDelay(c.failures); got != c.want { - t.Errorf("backoffDelay(%d) = %v, want %v", c.failures, got, c.want) - } - } -} - -// TestAttemptLimiterRejectsImmediateRetry is the integration check for -// the backoff machinery. The first call goes through; an immediate -// second call is rejected because the inter-attempt minimum hasn't -// elapsed. -func TestAttemptLimiterRejectsImmediateRetry(t *testing.T) { - l := newAttemptLimiter(10*time.Minute, 100) - if !l.allow("k") { - t.Fatal("first allow should pass") - } - if l.allow("k") { - t.Fatal("immediate second allow should be rejected by backoff") - } -} - -func TestAttemptLimiterResetClearsBackoff(t *testing.T) { - l := newAttemptLimiter(10*time.Minute, 100) - if !l.allow("k") { - t.Fatal("first allow should pass") - } - l.reset("k") - if !l.allow("k") { - t.Fatal("after reset, allow should pass again") - } -} - -func TestAttemptLimiterIndependentKeys(t *testing.T) { - l := newAttemptLimiter(10*time.Minute, 100) - if !l.allow("alice") { - t.Fatal("alice first allow should pass") - } - if !l.allow("bob") { - t.Fatal("bob's allow should pass independently of alice") - } -} diff --git a/apps/server/internal/httpserver/read_note_status_test.go b/apps/server/internal/httpserver/read_note_status_test.go deleted file mode 100644 index 849ed3c4..00000000 --- a/apps/server/internal/httpserver/read_note_status_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package httpserver - -import ( - "encoding/json" - "net/http" - "os" - "path/filepath" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -// The three answers /api/notes/read can give about a path, which clients read -// as three different things. Databases are composed from these reads, where -// "absent" means "adopt this bare CSV" and "failed" means "stop", so a status -// that blurs them is a data-loss bug rather than a cosmetic one (#kta report). -func TestReadNoteStatusesDistinguishMissingFromBroken(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o755); err != nil { - t.Fatalf("mkdir inbox: %v", err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "Real.md"), []byte("# Real\n"), 0o644); err != nil { - t.Fatalf("write note: %v", err) - } - // A database folder, which is exactly the shape a client asks about when - // it reads `.base/data.csv`. - if err := os.MkdirAll(filepath.Join(root, "inbox", "Db.base"), 0o755); err != nil { - t.Fatalf("mkdir db: %v", err) - } - - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - get := func(t *testing.T, path string) int { - t.Helper() - resp, err := client.Get(server.URL + "/api/notes/read?path=" + path) - if err != nil { - t.Fatalf("GET %s: %v", path, err) - } - defer resp.Body.Close() - return resp.StatusCode - } - - if got := get(t, "inbox%2FReal.md"); got != http.StatusOK { - t.Errorf("existing note: got %d, want 200", got) - } - // Absence is the caller's answer, not a failure: a client that cannot see - // this cannot create a database, because naming one probes for a free name. - if got := get(t, "inbox%2FDb.base%2Fdata.csv"); got != http.StatusNotFound { - t.Errorf("missing file: got %d, want 404", got) - } - // Reading a directory as a file is a malformed request, not a broken - // server. It answered 500 before, which sent a bug report chasing a - // server that was fine. - if got := get(t, "inbox%2FDb.base"); got != http.StatusBadRequest { - t.Errorf("directory read: got %d, want 400", got) - } -} - -// Clients otherwise have to probe for this behavior at runtime, so the server -// states it. Its absence is what marks a server from before 2.20.2. -func TestCapabilitiesReportMissingAsNotFound(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - BrowseRoots: []string{root}, - }) - - resp, err := http.Get(server.URL + "/api/capabilities") - if err != nil { - t.Fatalf("GET /api/capabilities: %v", err) - } - defer resp.Body.Close() - - var caps map[string]any - if err := json.NewDecoder(resp.Body).Decode(&caps); err != nil { - t.Fatalf("decode capabilities: %v", err) - } - if caps["reportsMissingAsNotFound"] != true { - t.Errorf("reportsMissingAsNotFound: got %v, want true", caps["reportsMissingAsNotFound"]) - } -} diff --git a/apps/server/internal/httpserver/security.go b/apps/server/internal/httpserver/security.go deleted file mode 100644 index 97e084fc..00000000 --- a/apps/server/internal/httpserver/security.go +++ /dev/null @@ -1,628 +0,0 @@ -package httpserver - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/hex" - "encoding/json" - "fmt" - "log" - "net" - "net/http" - "net/url" - "os" - "strings" - "sync" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -const ( - sessionCookieName = "zennotes_session" - sessionTTL = 30 * 24 * time.Hour -) - -type sessionStore struct { - mu sync.Mutex - sessions map[string]time.Time - // path, when non-empty, persists sessions to disk so browser logins survive a - // server restart (opt-in via ZENNOTES_PERSIST_SESSIONS). - path string -} - -type attemptLimiter struct { - mu sync.Mutex - window time.Duration - maxHits int - hits map[string][]time.Time -} - -type httpStatusError struct { - code int - msg string -} - -func (e httpStatusError) Error() string { - return e.msg -} - -func newSessionStore(path string) *sessionStore { - s := &sessionStore{sessions: make(map[string]time.Time), path: path} - s.load() - return s -} - -// load restores persisted sessions, dropping any already expired. Best-effort: -// a missing/unreadable/corrupt file just starts with no sessions. -func (s *sessionStore) load() { - if s.path == "" { - return - } - raw, err := os.ReadFile(s.path) - if err != nil { - return // no file yet (first run) or unreadable — start clean - } - var stored map[string]time.Time - if err := json.Unmarshal(raw, &stored); err != nil { - log.Printf("sessions: ignoring unreadable %q: %v", s.path, err) - return - } - now := time.Now() - for token, expiresAt := range stored { - if now.Before(expiresAt) { - s.sessions[token] = expiresAt - } - } -} - -// persistLocked writes the current sessions to disk (mode 0600). The caller must -// hold s.mu. Best-effort: a write failure only means sessions won't survive the -// next restart, so it is logged but not fatal. -func (s *sessionStore) persistLocked() { - if s.path == "" { - return - } - data, err := json.Marshal(s.sessions) - if err != nil { - return - } - if err := os.WriteFile(s.path, data, 0o600); err != nil { - log.Printf("sessions: could not persist to %q: %v", s.path, err) - } -} - -func (s *sessionStore) create() (string, time.Time, error) { - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - return "", time.Time{}, err - } - token := hex.EncodeToString(buf) - expiresAt := time.Now().Add(sessionTTL) - s.mu.Lock() - s.sessions[token] = expiresAt - s.persistLocked() - s.mu.Unlock() - return token, expiresAt, nil -} - -func (s *sessionStore) isValid(token string) bool { - if strings.TrimSpace(token) == "" { - return false - } - now := time.Now() - s.mu.Lock() - defer s.mu.Unlock() - for key, expiresAt := range s.sessions { - if now.After(expiresAt) { - delete(s.sessions, key) - } - } - expiresAt, ok := s.sessions[token] - return ok && now.Before(expiresAt) -} - -func (s *sessionStore) delete(token string) { - if strings.TrimSpace(token) == "" { - return - } - s.mu.Lock() - delete(s.sessions, token) - s.persistLocked() - s.mu.Unlock() -} - -func (s *sessionStore) deleteAll() { - s.mu.Lock() - s.sessions = make(map[string]time.Time) - s.persistLocked() - s.mu.Unlock() -} - -func newAttemptLimiter(window time.Duration, maxHits int) *attemptLimiter { - return &attemptLimiter{ - window: window, - maxHits: maxHits, - hits: make(map[string][]time.Time), - } -} - -func (l *attemptLimiter) allow(key string) bool { - if strings.TrimSpace(key) == "" { - key = "unknown" - } - now := time.Now() - cutoff := now.Add(-l.window) - - l.mu.Lock() - defer l.mu.Unlock() - - history := l.hits[key][:0] - for _, ts := range l.hits[key] { - if ts.After(cutoff) { - history = append(history, ts) - } - } - - // Exponential backoff between consecutive attempts. The window-based - // cap below is the absolute ceiling; the per-attempt backoff makes - // even the first few failures cost real time. - if n := len(history); n > 0 { - if wait := backoffDelay(n); now.Sub(history[n-1]) < wait { - l.hits[key] = history - return false - } - } - if len(history) >= l.maxHits { - l.hits[key] = history - return false - } - history = append(history, now) - l.hits[key] = history - return true -} - -// backoffDelay returns the minimum time the caller must wait before the -// (consecutiveFailures+1)-th attempt is allowed: 0, 1, 2, 4, 8, 16, 32, -// then capped at 60s. -func backoffDelay(consecutiveFailures int) time.Duration { - if consecutiveFailures < 1 { - return 0 - } - n := consecutiveFailures - 1 - if n > 6 { - n = 6 - } - d := time.Duration(1< 60*time.Second { - d = 60 * time.Second - } - return d -} - -func (l *attemptLimiter) reset(key string) { - l.mu.Lock() - delete(l.hits, key) - l.mu.Unlock() -} - -// AllowAllOrigins is the wildcard operators reach for first. Comparing it -// literally, as any other string, meant ZENNOTES_ALLOWED_ORIGINS="*" locked -// everything out with no hint that it was unsupported. (#482) -const AllowAllOrigins = "*" - -// NullOrigin is what a browser sends for an opaque origin — a sandboxed -// iframe, a data: document, or a page loaded over file:// in most engines. -const NullOrigin = "null" - -// normalizeOrigin canonicalises an origin for comparison, and returns "" for -// anything that isn't one. -// -// Origins are not always scheme+host. Browsers send the literal "null" for -// opaque origins, and "file://" (no host) for local pages in some engines; -// requiring a host silently dropped both, so an operator who listed them -// verbatim still saw them rejected with no way to allow them. Both are -// preserved here so they can be configured. (#482) -func normalizeOrigin(raw string) string { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return "" - } - if trimmed == AllowAllOrigins { - return AllowAllOrigins - } - if strings.EqualFold(trimmed, NullOrigin) { - return NullOrigin - } - parsed, err := url.Parse(trimmed) - if err != nil || parsed.Scheme == "" { - return "" - } - if parsed.Host == "" { - // Scheme-only origin such as "file://". Anything else without a host — - // "mailto:someone", "https:///path" — is not an origin. - if parsed.Opaque != "" || parsed.Path != "" { - return "" - } - return strings.ToLower(parsed.Scheme) + "://" - } - return fmt.Sprintf("%s://%s", strings.ToLower(parsed.Scheme), strings.ToLower(parsed.Host)) -} - -// peerIsTrustedProxy reports whether the immediate TCP peer (r.RemoteAddr) -// is in the configured ZENNOTES_TRUSTED_PROXIES set. Forwarded-* headers -// are only honoured when this is true. -func (s *Server) peerIsTrustedProxy(r *http.Request) bool { - cfg := s.currentConfig() - if len(cfg.TrustedProxies) == 0 { - return false - } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - host = r.RemoteAddr - } - ip := net.ParseIP(strings.Trim(host, "[]")) - if ip == nil { - return false - } - for _, n := range cfg.TrustedProxies { - if n.Contains(ip) { - return true - } - } - return false -} - -// effectiveScheme returns "https" if the request is genuinely on TLS or -// arrived through a trusted proxy that declares X-Forwarded-Proto: https. -// Untrusted X-Forwarded-Proto headers are ignored. -func (s *Server) effectiveScheme(r *http.Request) string { - if r.TLS != nil { - return "https" - } - if s.peerIsTrustedProxy(r) { - if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); forwarded != "" { - return strings.ToLower(forwarded) - } - } - if s.currentConfig().BehindTLS { - return "https" - } - return "http" -} - -func (s *Server) requestOrigin(r *http.Request) string { - scheme := s.effectiveScheme(r) - host := strings.TrimSpace(r.Host) - if s.peerIsTrustedProxy(r) { - if forwardedHost := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); forwardedHost != "" { - host = forwardedHost - } - } - if host == "" { - return "" - } - return fmt.Sprintf("%s://%s", scheme, strings.ToLower(host)) -} - -func isLoopbackBind(bind string) bool { - host, _, err := net.SplitHostPort(bind) - if err != nil { - host = bind - } - host = strings.Trim(host, "[]") - if host == "" { - return false - } - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} - -func isLoopbackOrigin(origin string) bool { - parsed, err := url.Parse(origin) - if err != nil { - return false - } - host := parsed.Hostname() - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} - -// originDecision is how a request's Origin fared: whether it may be echoed -// back at all, and whether credentials (the session cookie) may ride with it. -type originDecision struct { - allowed bool - // Credentials are withheld for a wildcard match: echoing - // Access-Control-Allow-Origin for *any* site alongside - // Allow-Credentials: true would let any page a user visits drive their - // session. Bearer-token clients are unaffected — they attach the token - // themselves. (#482) - credentials bool -} - -func (s *Server) originDecisionFor(r *http.Request, origin string) originDecision { - if origin == "" { - return originDecision{allowed: true, credentials: true} - } - normalized := normalizeOrigin(origin) - if normalized == "" { - return originDecision{} - } - if normalized == s.requestOrigin(r) { - return originDecision{allowed: true, credentials: true} - } - - cfg := s.currentConfig() - wildcard := false - for _, allowed := range cfg.AllowedOrigins { - switch normalizeOrigin(allowed) { - case normalized: - return originDecision{allowed: true, credentials: true} - case AllowAllOrigins: - wildcard = true - } - } - if wildcard { - return originDecision{allowed: true} - } - - if (cfg.DevMode || isLoopbackBind(cfg.Bind)) && isLoopbackOrigin(normalized) { - return originDecision{allowed: true, credentials: true} - } - - return originDecision{} -} - -func (s *Server) isAllowedOrigin(r *http.Request, origin string) bool { - return s.originDecisionFor(r, origin).allowed -} - -func (s *Server) corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - origin := strings.TrimSpace(r.Header.Get("Origin")) - if origin != "" { - if decision := s.originDecisionFor(r, origin); decision.allowed { - w.Header().Set("Access-Control-Allow-Origin", origin) - if decision.credentials { - w.Header().Set("Access-Control-Allow-Credentials", "true") - } - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, If-Match") - w.Header().Add("Vary", "Origin") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - } else { - s.logCORSRejection(origin) - } - } - next.ServeHTTP(w, r) - }) -} - -// logCORSRejection emits one log line per unique origin so a -// misconfigured ZENNOTES_ALLOWED_ORIGINS surfaces in operator logs -// instead of silently failing in the browser. -func (s *Server) logCORSRejection(origin string) { - if _, loaded := s.loggedOrigins.LoadOrStore(origin, struct{}{}); loaded { - return - } - log.Printf( - "CORS rejected origin %q; add it verbatim to ZENNOTES_ALLOWED_ORIGINS (comma-separated) to allow it, "+ - "or set ZENNOTES_ALLOWED_ORIGINS=* to allow any origin without credentials. "+ - "The ZenNotes desktop app talks to the server from its main process and sends no Origin, so it is "+ - "never affected by this — browser and WebView clients are.", - origin, - ) -} - -func contentSecurityPolicy() string { - return strings.Join([]string{ - "default-src 'self'", - "script-src 'self' 'unsafe-eval'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: https:", - "media-src 'self' data: blob:", - "font-src 'self' data:", - "worker-src 'self' blob:", - "connect-src 'self' ws: wss: https:", - "frame-src 'self' data: blob: https://www.youtube-nocookie.com https://player.vimeo.com", - "object-src 'none'", - "base-uri 'none'", - "form-action 'none'", - // 'self', not 'none': the web client shows a vault's PDFs in an iframe - // served by this server, and the PDF response carries this policy too, - // so 'none' forbade the app's own same-origin frame (#121). Third-party - // framing stays blocked. - "frame-ancestors 'self'", - "manifest-src 'self'", - }, "; ") -} - -func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Security-Policy", contentSecurityPolicy()) - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") - if s.effectiveScheme(r) == "https" { - w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") - } - next.ServeHTTP(w, r) - }) -} - -func sessionStatusPayload(authenticated bool, cfg config.Config) map[string]any { - return map[string]any{ - "authenticated": authenticated, - "authRequired": strings.TrimSpace(cfg.AuthToken) != "", - "supportsSessionLogin": true, - } -} - -func (s *Server) sessionCookie(r *http.Request, token string, expiresAt time.Time) *http.Cookie { - cookie := &http.Cookie{ - Name: sessionCookieName, - Value: token, - Path: "/api", - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Expires: expiresAt, - } - if s.effectiveScheme(r) == "https" { - cookie.Secure = true - } - return cookie -} - -func (s *Server) clearSessionCookie(r *http.Request) *http.Cookie { - cookie := s.sessionCookie(r, "", time.Unix(0, 0)) - cookie.MaxAge = -1 - return cookie -} - -func (s *Server) requestAuthenticatedViaSession(r *http.Request) bool { - cookie, err := r.Cookie(sessionCookieName) - if err != nil { - return false - } - return s.sessions.isValid(cookie.Value) -} - -// clientAddressKey returns a stable identifier for rate-limit keying. It -// honours X-Forwarded-For only when the immediate peer is a configured -// trusted proxy; otherwise it returns the TCP peer IP. This prevents -// untrusted clients from spoofing rate-limit buckets via header. -func (s *Server) clientAddressKey(r *http.Request) string { - if s.peerIsTrustedProxy(r) { - if fwd := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); fwd != "" { - if h, _, err := net.SplitHostPort(fwd); err == nil { - return h - } - return fwd - } - } - host := strings.TrimSpace(r.RemoteAddr) - if h, _, err := net.SplitHostPort(host); err == nil { - return h - } - return host -} - -func (s *Server) sessionStatus(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - writeJSON(w, http.StatusOK, sessionStatusPayload(s.requestAuthenticatedViaSession(r), cfg)) -} - -func (s *Server) sessionLogin(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - if !s.loginLimiter.allow(s.clientAddressKey(r)) { - http.Error(w, "too many login attempts", http.StatusTooManyRequests) - return - } - - if strings.TrimSpace(cfg.AuthToken) == "" { - writeJSON(w, http.StatusOK, sessionStatusPayload(true, cfg)) - return - } - - var req struct { - Token string `json:"token"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if subtleCompare(strings.TrimSpace(req.Token), strings.TrimSpace(cfg.AuthToken)) { - s.loginLimiter.reset(s.clientAddressKey(r)) - token, expiresAt, err := s.sessions.create() - if err != nil { - writeError(w, err) - return - } - http.SetCookie(w, s.sessionCookie(r, token, expiresAt)) - writeJSON(w, http.StatusOK, sessionStatusPayload(true, cfg)) - return - } - - http.Error(w, "unauthorized", http.StatusUnauthorized) -} - -func (s *Server) sessionLogout(w http.ResponseWriter, r *http.Request) { - if cookie, err := r.Cookie(sessionCookieName); err == nil { - s.sessions.delete(cookie.Value) - } - http.SetCookie(w, s.clearSessionCookie(r)) - writeJSON(w, http.StatusOK, sessionStatusPayload(false, s.currentConfig())) -} - -// sessionRotateToken replaces the bootstrap auth token with a caller- -// supplied value. Requires the *current* token in the body even when -// the request is authenticated, so a stolen session alone cannot rotate -// the secret. All existing sessions are invalidated; clients must -// re-login with the new token. -func (s *Server) sessionRotateToken(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, 4<<10) - var req struct { - CurrentToken string `json:"currentToken"` - NewToken string `json:"newToken"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - current := strings.TrimSpace(req.CurrentToken) - next := strings.TrimSpace(req.NewToken) - if len(next) < 16 { - http.Error(w, "new token must be at least 16 characters", http.StatusBadRequest) - return - } - if next == current { - http.Error(w, "new token must differ from current", http.StatusBadRequest) - return - } - - s.mu.Lock() - cfgCurrent := s.Config.AuthToken - sourceCurrent := s.Config.AuthTokenSource - if sourceCurrent == config.AuthTokenSourceEnv || sourceCurrent == config.AuthTokenSourceFile { - s.mu.Unlock() - http.Error(w, "auth token is managed outside ZenNotes; update the token source and restart", http.StatusConflict) - return - } - if !subtleCompare(current, strings.TrimSpace(cfgCurrent)) { - s.mu.Unlock() - http.Error(w, "current token mismatch", http.StatusUnauthorized) - return - } - s.Config.AuthToken = next - s.Config.AuthTokenSource = config.AuthTokenSourceConfig - cfgCopy := s.Config - s.mu.Unlock() - - if err := config.SaveHost(cfgCopy); err != nil { - s.mu.Lock() - s.Config.AuthToken = cfgCurrent - s.Config.AuthTokenSource = sourceCurrent - s.mu.Unlock() - writeError(w, err) - return - } - s.sessions.deleteAll() - http.SetCookie(w, s.clearSessionCookie(r)) - writeJSON(w, http.StatusOK, map[string]any{"rotated": true}) -} - -func subtleCompare(left string, right string) bool { - if len(left) == 0 || len(right) == 0 { - return false - } - return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1 -} diff --git a/apps/server/internal/httpserver/security_test.go b/apps/server/internal/httpserver/security_test.go deleted file mode 100644 index 357be339..00000000 --- a/apps/server/internal/httpserver/security_test.go +++ /dev/null @@ -1,562 +0,0 @@ -package httpserver - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "log" - "mime/multipart" - "net" - "net/http" - "net/http/cookiejar" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" -) - -func newTestServer(t *testing.T, cfg config.Config) (*httptest.Server, *vault.Vault) { - t.Helper() - - v, err := vault.New(cfg.VaultPath, vault.Options{ - FileMode: cfg.VaultFileMode, - DirMode: cfg.VaultDirMode, - MaxAssetBytes: cfg.MaxAssetBytes, - }) - if err != nil { - t.Fatalf("vault.New: %v", err) - } - - server := httptest.NewServer(New(v, nil, nil, cfg).Router()) - t.Cleanup(server.Close) - return server, v -} - -// loginAndJar logs in with the given token and returns a cookiejar -// that subsequent calls can reuse. -func loginAndJar(t *testing.T, server *httptest.Server, token string) http.CookieJar { - t.Helper() - jar, err := cookiejar.New(nil) - if err != nil { - t.Fatalf("cookiejar.New: %v", err) - } - client := &http.Client{Jar: jar} - body, _ := json.Marshal(map[string]string{"token": token}) - resp, err := client.Post(server.URL+"/api/session/login", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("login: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("login status: %d", resp.StatusCode) - } - return jar -} - -func TestSessionLoginProtectsVaultRoutes(t *testing.T) { - root := t.TempDir() - server, v := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - - unauthenticatedResp, err := http.Get(server.URL + "/api/vault") - if err != nil { - t.Fatalf("GET /api/vault without auth: %v", err) - } - defer unauthenticatedResp.Body.Close() - if unauthenticatedResp.StatusCode != http.StatusUnauthorized { - t.Fatalf("expected 401 without auth, got %d", unauthenticatedResp.StatusCode) - } - - jar, err := cookiejar.New(nil) - if err != nil { - t.Fatalf("cookiejar.New: %v", err) - } - client := &http.Client{Jar: jar} - - loginBody, err := json.Marshal(map[string]string{"token": "secret-token"}) - if err != nil { - t.Fatalf("json.Marshal: %v", err) - } - loginResp, err := client.Post(server.URL+"/api/session/login", "application/json", bytes.NewReader(loginBody)) - if err != nil { - t.Fatalf("POST /api/session/login: %v", err) - } - defer loginResp.Body.Close() - if loginResp.StatusCode != http.StatusOK { - t.Fatalf("expected 200 from login, got %d", loginResp.StatusCode) - } - - loginURL, err := url.Parse(server.URL + "/api/session/login") - if err != nil { - t.Fatalf("url.Parse: %v", err) - } - if len(jar.Cookies(loginURL)) == 0 { - t.Fatal("expected login to set a session cookie") - } - - authedResp, err := client.Get(server.URL + "/api/vault") - if err != nil { - t.Fatalf("GET /api/vault with session cookie: %v", err) - } - defer authedResp.Body.Close() - if authedResp.StatusCode != http.StatusOK { - t.Fatalf("expected 200 with session cookie, got %d", authedResp.StatusCode) - } - - var info struct { - Root string `json:"root"` - } - if err := json.NewDecoder(authedResp.Body).Decode(&info); err != nil { - t.Fatalf("decode /api/vault response: %v", err) - } - if info.Root != v.Root() { - t.Fatalf("expected vault root %q, got %q", v.Root(), info.Root) - } -} - -func TestBrowseRootsEnforced(t *testing.T) { - parent := t.TempDir() - allowedRoot := filepath.Join(parent, "allowed") - blockedRoot := filepath.Join(parent, "blocked") - if err := os.MkdirAll(allowedRoot, 0o755); err != nil { - t.Fatalf("MkdirAll allowedRoot: %v", err) - } - if err := os.MkdirAll(blockedRoot, 0o755); err != nil { - t.Fatalf("MkdirAll blockedRoot: %v", err) - } - - server, _ := newTestServer(t, config.Config{ - VaultPath: allowedRoot, - DefaultVaultPath: allowedRoot, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{allowedRoot}, - }) - - request, err := http.NewRequest(http.MethodGet, server.URL+"/api/fs/browse?path="+url.QueryEscape(blockedRoot), nil) - if err != nil { - t.Fatalf("http.NewRequest: %v", err) - } - request.Header.Set("Authorization", "Bearer secret-token") - - response, err := http.DefaultClient.Do(request) - if err != nil { - t.Fatalf("GET /api/fs/browse outside allowed root: %v", err) - } - defer response.Body.Close() - - if response.StatusCode != http.StatusForbidden { - t.Fatalf("expected 403 for blocked browse root, got %d", response.StatusCode) - } -} - -// --- T1.2 upload size limit --- - -func TestUploadAssetRespects413(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - MaxAssetBytes: 64, - MaxNoteBytes: 64, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - body := &bytes.Buffer{} - mw := multipart.NewWriter(body) - _ = mw.WriteField("notePath", "note.md") - part, _ := mw.CreateFormFile("file", "x.bin") - _, _ = part.Write(bytes.Repeat([]byte("a"), 1024)) - _ = mw.Close() - - resp, err := client.Post(server.URL+"/api/assets/upload", mw.FormDataContentType(), body) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusRequestEntityTooLarge && resp.StatusCode != http.StatusBadRequest { - // http.MaxBytesReader returns 400 before ParseMultipartForm runs - // for over-cap requests; ImportAsset's own ErrAssetTooLarge maps - // to 413 if the multipart parser somehow lets it through. Either - // is acceptable here. - t.Fatalf("expected 4xx large-body rejection, got %d", resp.StatusCode) - } -} - -func TestWriteNoteRespects413(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - MaxNoteBytes: 64, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - huge := strings.Repeat("a", 200000) - body, _ := json.Marshal(map[string]string{"path": "x.md", "body": huge}) - resp, err := client.Post(server.URL+"/api/notes/write", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode/100 != 4 { - t.Fatalf("expected 4xx for oversized note, got %d", resp.StatusCode) - } -} - -// --- T2.4 trusted-proxies gate --- - -func TestForwardedProtoIgnoredWithoutTrust(t *testing.T) { - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - }) - body, _ := json.Marshal(map[string]string{"token": "secret-token"}) - req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/session/login", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Forwarded-Proto", "https") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("login: %d", resp.StatusCode) - } - for _, c := range resp.Cookies() { - if c.Name == "zennotes_session" && c.Secure { - t.Fatalf("Secure cookie set despite untrusted X-Forwarded-Proto") - } - } -} - -func TestForwardedProtoHonouredWhenTrusted(t *testing.T) { - _, loop, _ := net.ParseCIDR("127.0.0.0/8") - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - TrustedProxies: []net.IPNet{*loop}, - }) - body, _ := json.Marshal(map[string]string{"token": "secret-token"}) - req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/session/login", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Forwarded-Proto", "https") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("login: %d", resp.StatusCode) - } - var found bool - for _, c := range resp.Cookies() { - if c.Name == "zennotes_session" && c.Secure { - found = true - } - } - if !found { - t.Fatalf("expected Secure cookie when peer is trusted and X-Forwarded-Proto=https") - } -} - -// --- T2.5 HSTS --- - -func TestHSTSOnlyWhenEffectiveHTTPS(t *testing.T) { - root := t.TempDir() - - // Without BehindTLS or trusted proxies: no HSTS. - plain, _ := newTestServer(t, config.Config{VaultPath: root, Bind: "127.0.0.1:7878"}) - resp, err := http.Get(plain.URL + "/api/healthz") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if got := resp.Header.Get("Strict-Transport-Security"); got != "" { - t.Fatalf("HSTS unexpectedly sent on plain HTTP: %q", got) - } - - // With BehindTLS=true: HSTS sent. - tls, _ := newTestServer(t, config.Config{VaultPath: t.TempDir(), Bind: "127.0.0.1:7878", BehindTLS: true}) - resp, err = http.Get(tls.URL + "/api/healthz") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if got := resp.Header.Get("Strict-Transport-Security"); !strings.Contains(got, "max-age=") { - t.Fatalf("expected HSTS header with BehindTLS=1, got %q", got) - } -} - -// --- T3.9 token rotation --- - -func TestRotateTokenFullFlow(t *testing.T) { - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "current-token-xxxxxxxx", - // SaveHost would otherwise touch the user config file. Redirect: - }) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "host.json")) - - jar := loginAndJar(t, server, "current-token-xxxxxxxx") - client := &http.Client{Jar: jar} - - // Rotate. - body, _ := json.Marshal(map[string]string{ - "currentToken": "current-token-xxxxxxxx", - "newToken": "rotated-token-yyyyyyy", - }) - resp, err := client.Post(server.URL+"/api/session/rotate-token", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("rotate: expected 200, got %d", resp.StatusCode) - } - - // Old session is now invalidated. - resp, err = client.Get(server.URL + "/api/vault") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("old session should be invalidated, got %d", resp.StatusCode) - } - - // New token logs in (do this first so the success resets the rate - // limiter; otherwise the next failed attempt would trip the - // inter-attempt backoff and the assertion below would 429). - loginNew, _ := json.Marshal(map[string]string{"token": "rotated-token-yyyyyyy"}) - resp, err = http.Post(server.URL+"/api/session/login", "application/json", bytes.NewReader(loginNew)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("new token should log in, got %d", resp.StatusCode) - } - - // Old token can no longer log in. - loginOld, _ := json.Marshal(map[string]string{"token": "current-token-xxxxxxxx"}) - resp, err = http.Post(server.URL+"/api/session/login", "application/json", bytes.NewReader(loginOld)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - t.Fatalf("old token should be rejected after rotation, got %d", resp.StatusCode) - } -} - -func TestRotateTokenRejectsShortToken(t *testing.T) { - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "current-token-xxxxxxxx", - }) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "host.json")) - jar := loginAndJar(t, server, "current-token-xxxxxxxx") - client := &http.Client{Jar: jar} - - body, _ := json.Marshal(map[string]string{ - "currentToken": "current-token-xxxxxxxx", - "newToken": "tooshort", - }) - resp, err := client.Post(server.URL+"/api/session/rotate-token", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("expected 400 for short new token, got %d", resp.StatusCode) - } -} - -func TestRotateTokenRejectsWrongCurrent(t *testing.T) { - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "current-token-xxxxxxxx", - }) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "host.json")) - jar := loginAndJar(t, server, "current-token-xxxxxxxx") - client := &http.Client{Jar: jar} - - body, _ := json.Marshal(map[string]string{ - "currentToken": "wrong-token", - "newToken": "rotated-token-yyyyyyy", - }) - resp, err := client.Post(server.URL+"/api/session/rotate-token", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("expected 401 for wrong current, got %d", resp.StatusCode) - } -} - -func TestRotateTokenRejectsExternalTokenSource(t *testing.T) { - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - AuthToken: "current-token-xxxxxxxx", - AuthTokenSource: config.AuthTokenSourceEnv, - }) - t.Setenv("ZENNOTES_CONFIG_PATH", filepath.Join(t.TempDir(), "host.json")) - jar := loginAndJar(t, server, "current-token-xxxxxxxx") - client := &http.Client{Jar: jar} - - body, _ := json.Marshal(map[string]string{ - "currentToken": "current-token-xxxxxxxx", - "newToken": "rotated-token-yyyyyyy", - }) - resp, err := client.Post(server.URL+"/api/session/rotate-token", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusConflict { - t.Fatalf("expected 409 for externally managed token, got %d", resp.StatusCode) - } -} - -func TestWriteErrorDoesNotExposeInternalDetails(t *testing.T) { - rec := httptest.NewRecorder() - writeError(rec, errors.New("open /Users/example/private/vault/secret.md: permission denied")) - - if rec.Code != http.StatusInternalServerError { - t.Fatalf("expected 500, got %d", rec.Code) - } - body := rec.Body.String() - if strings.Contains(body, "/Users/example") || strings.Contains(body, "permission denied") { - t.Fatalf("internal error leaked details: %q", body) - } - if !strings.Contains(body, "internal server error") { - t.Fatalf("expected generic error body, got %q", body) - } -} - -func TestIsLoopbackBindTreatsEmptyHostAsNonLoopback(t *testing.T) { - cases := []struct { - bind string - want bool - }{ - {":7878", false}, - {"0.0.0.0:7878", false}, - {"[::]:7878", false}, - {"127.0.0.1:7878", true}, - {"[::1]:7878", true}, - {"localhost:7878", true}, - } - - for _, tc := range cases { - if got := isLoopbackBind(tc.bind); got != tc.want { - t.Fatalf("isLoopbackBind(%q) = %v, want %v", tc.bind, got, tc.want) - } - } -} - -// --- T3.10 CORS rejection log --- - -func TestCORSRejectionLoggedOncePerOrigin(t *testing.T) { - var buf strings.Builder - prev := log.Writer() - log.SetOutput(&buf) - t.Cleanup(func() { log.SetOutput(prev) }) - - server, _ := newTestServer(t, config.Config{ - VaultPath: t.TempDir(), - Bind: "127.0.0.1:7878", - }) - send := func(origin string) { - req, _ := http.NewRequest(http.MethodGet, server.URL+"/api/healthz", nil) - req.Header.Set("Origin", origin) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - } - - send("https://evil.example.com") - send("https://evil.example.com") - send("https://other.example.com") - - out := buf.String() - first := strings.Count(out, `evil.example.com"`) - other := strings.Count(out, `other.example.com"`) - if first != 1 { - t.Errorf("expected exactly one log for evil.example.com, got %d:\n%s", first, out) - } - if other != 1 { - t.Errorf("expected exactly one log for other.example.com, got %d:\n%s", other, out) - } -} - -func TestSessionStorePersistence(t *testing.T) { - path := filepath.Join(t.TempDir(), "sessions.json") - - // A store with a path survives a "restart" (a fresh store on the same file). - first := newSessionStore(path) - token, _, err := first.create() - if err != nil { - t.Fatal(err) - } - if !newSessionStore(path).isValid(token) { - t.Fatal("session should survive a restart when persistence is on") - } - - // Logout removes it from disk too. - newSessionStore(path).delete(token) - if newSessionStore(path).isValid(token) { - t.Fatal("deleted session should not come back after a restart") - } -} - -func TestSessionStoreNoPersistenceByDefault(t *testing.T) { - // An empty path keeps the store in-memory; nothing survives a "restart". - first := newSessionStore("") - token, _, err := first.create() - if err != nil { - t.Fatal(err) - } - if newSessionStore("").isValid(token) { - t.Fatal("without a path, sessions must not persist") - } -} - -func TestSessionStoreDropsExpiredOnLoad(t *testing.T) { - path := filepath.Join(t.TempDir(), "sessions.json") - data, _ := json.Marshal(map[string]time.Time{"stale": time.Now().Add(-time.Hour)}) - if err := os.WriteFile(path, data, 0o600); err != nil { - t.Fatal(err) - } - if newSessionStore(path).isValid("stale") { - t.Fatal("an expired persisted session should be dropped on load") - } -} diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go deleted file mode 100644 index 9b4ffafb..00000000 --- a/apps/server/internal/httpserver/server.go +++ /dev/null @@ -1,1397 +0,0 @@ -package httpserver - -import ( - "encoding/json" - "errors" - "io/fs" - "log" - "mime" - "net/http" - "os" - "path/filepath" - "runtime" - "sort" - "strings" - "sync" - "syscall" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" - "github.com/ZenNotes/zennotes/apps/server/internal/watcher" - "github.com/coder/websocket" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" -) - -// Per-request body envelope allowances applied on top of the -// configured note/asset size limits. They cover JSON keys + structural -// overhead (writeNote) and multipart boundaries + form fields -// (uploadAsset). Generous enough to never reject a payload that's -// within the configured limit. -const ( - jsonEnvelopeBytes int64 = 64 << 10 // 64 KiB - multipartOverheadBytes int64 = 256 << 10 // 256 KiB -) - -type Server struct { - mu sync.RWMutex - Config config.Config - Vault *vault.Vault - Watcher *watcher.Watcher - Static fs.FS // embedded web bundle, may be nil in dev - sessions *sessionStore - loginLimiter *attemptLimiter - wsRejectLimiter *attemptLimiter - loggedOrigins sync.Map // origin -> struct{}; dedupes CORS-rejection logs -} - -func init() { - // Go's builtin MIME table lacks web-font types, and static assets are served - // with X-Content-Type-Options: nosniff, so register them explicitly. The web - // bundle self-hosts Excalidraw's woff2 fonts under /excalidraw-assets/. - _ = mime.AddExtensionType(".woff2", "font/woff2") - _ = mime.AddExtensionType(".woff", "font/woff") - _ = mime.AddExtensionType(".ttf", "font/ttf") - _ = mime.AddExtensionType(".otf", "font/otf") -} - -func New(v *vault.Vault, w *watcher.Watcher, static fs.FS, cfg config.Config) *Server { - // Opt-in: persist browser sessions next to the host config so they survive a - // restart. Off by default — an empty path keeps the store purely in-memory. - sessionsPath := "" - if cfg.PersistSessions { - sessionsPath = config.SessionsPath() - } - return &Server{ - Vault: v, - Watcher: w, - Static: static, - Config: cfg, - sessions: newSessionStore(sessionsPath), - loginLimiter: newAttemptLimiter(10*time.Minute, 10), - wsRejectLimiter: newAttemptLimiter(1*time.Minute, 20), - } -} - -func (s *Server) currentVault() *vault.Vault { - s.mu.RLock() - defer s.mu.RUnlock() - return s.Vault -} - -func (s *Server) currentWatcher() *watcher.Watcher { - s.mu.RLock() - defer s.mu.RUnlock() - return s.Watcher -} - -func (s *Server) currentConfig() config.Config { - s.mu.RLock() - defer s.mu.RUnlock() - return s.Config -} - -func (s *Server) switchVaultRoot(nextPath string) (*vault.Vault, error) { - cfg := s.currentConfig() - nextVault, err := vault.New(nextPath, vault.Options{ - FileMode: cfg.VaultFileMode, - DirMode: cfg.VaultDirMode, - MaxAssetBytes: cfg.MaxAssetBytes, - }) - if err != nil { - return nil, err - } - // Non-fatal: a vault switch must not fail just because inotify is - // unavailable; fall back to a no-op watcher in that case. (#179) - nextWatcher := watcher.StartOrDisabled(nextVault.Root(), cfg.DisableWatcher) - - s.mu.Lock() - prevWatcher := s.Watcher - s.Vault = nextVault - s.Watcher = nextWatcher - s.Config.VaultPath = nextVault.Root() - cfg = s.Config - s.mu.Unlock() - - if prevWatcher != nil { - prevWatcher.Close() - } - _ = config.SaveHost(cfg) - return nextVault, nil -} - -func (s *Server) Router() http.Handler { - inner := chi.NewRouter() - inner.Route("/api", func(r chi.Router) { - r.Get("/healthz", s.healthz) - r.Get("/version", s.version) - r.Get("/capabilities", s.capabilities) - r.Get("/platform", s.platform) - r.Get("/session", s.sessionStatus) - r.Post("/session/login", s.sessionLogin) - r.Post("/session/logout", s.sessionLogout) - - r.Group(func(r chi.Router) { - r.Use(s.requireAuth) - s.registerProtectedRoutes(r) - }) - }) - - // Legacy root-level API compatibility. Keep this around so the web client - // still works during partial restarts or when an older bundle is cached. - inner.Get("/healthz", s.healthz) - inner.Get("/version", s.version) - inner.Get("/capabilities", s.capabilities) - inner.Get("/platform", s.platform) - inner.Get("/session", s.sessionStatus) - inner.Post("/session/login", s.sessionLogin) - inner.Post("/session/logout", s.sessionLogout) - inner.Group(func(r chi.Router) { - r.Use(s.requireAuth) - s.registerProtectedRoutes(r) - }) - - // Static / PWA fallback. - if s.Static != nil { - inner.Get("/*", s.serveStatic) - } - - outer := chi.NewRouter() - outer.Use(middleware.RequestID) - // Intentionally not using middleware.RealIP: it rewrites - // r.RemoteAddr from X-Forwarded-For unconditionally, which would - // let any client spoof the rate-limit and audit identity. - // clientAddressKey() does trust-aware extraction instead. - outer.Use(s.securityHeadersMiddleware) - outer.Use(s.corsMiddleware) - outer.Use(middleware.Recoverer) - - basePath := s.currentConfig().BasePath - if basePath != "" { - outer.Mount(basePath, inner) - return outer - } - outer.Mount("/", inner) - return outer -} - -func (s *Server) registerProtectedRoutes(r chi.Router) { - r.Post("/session/rotate-token", s.sessionRotateToken) - - r.Get("/vault", s.vaultInfo) - r.Get("/vault/settings", s.vaultSettings) - r.Post("/vault/settings", s.setVaultSettings) - r.Post("/vault/select", s.selectVault) - r.Get("/fs/browse", s.browseDirectories) - - r.Get("/notes", s.listNotes) - r.Get("/folders", s.listFolders) - r.Get("/assets", s.listAssets) - r.Get("/assets/exists", s.assetsExists) - r.Get("/assets/raw", s.rawAsset) - r.Post("/assets/upload", s.uploadAsset) - r.Post("/assets/rename", s.renameAsset) - r.Post("/assets/move", s.moveAsset) - r.Post("/assets/duplicate", s.duplicateAsset) - r.Post("/assets/delete", s.deleteAsset) - r.Get("/assets/deleted", s.listDeletedAssets) - r.Post("/assets/restore", s.restoreDeletedAsset) - r.Post("/assets/purge", s.purgeDeletedAsset) - r.Post("/assets/empty-deleted", s.emptyDeletedAssets) - - r.Get("/notes/read", s.readNote) - r.Get("/comments/read", s.readComments) - r.Post("/comments/write", s.writeComments) - r.Post("/notes/write", s.writeNote) - r.Post("/notes/create", s.createNote) - r.Post("/excalidraw/create", s.createExcalidraw) - r.Post("/notes/rename", s.renameNote) - r.Post("/notes/delete", s.deleteNote) - r.Post("/notes/trash", s.trashNote) - r.Post("/notes/restore", s.restoreNote) - r.Post("/notes/empty-trash", s.emptyTrash) - r.Post("/notes/archive", s.archiveNote) - r.Post("/notes/unarchive", s.unarchiveNote) - r.Post("/notes/duplicate", s.duplicateNote) - r.Post("/notes/move", s.moveNote) - - r.Post("/folders/create", s.createFolder) - r.Post("/folders/rename", s.renameFolder) - r.Post("/folders/delete", s.deleteFolder) - r.Post("/folders/duplicate", s.duplicateFolder) - - r.Get("/templates", s.listTemplates) - r.Get("/templates/read", s.readTemplate) - r.Post("/templates/write", s.writeTemplate) - r.Post("/templates/delete", s.deleteTemplate) - - r.Get("/search/capabilities", s.searchCapabilities) - r.Get("/search/text", s.searchText) - - r.Get("/tasks", s.allTasks) - r.Get("/tasks/for", s.tasksFor) - - r.Post("/demo/generate", s.demoGenerate) - r.Post("/demo/remove", s.demoRemove) - - r.Get("/workflows", s.listWorkflows) - r.Post("/workflows/write", s.writeWorkflow) - r.Post("/workflows/delete", s.deleteWorkflow) - r.Post("/workflows/apply", s.applyWorkflow) - r.Post("/workflows/undo", s.undoWorkflowRun) - r.Get("/workflows/runs", s.listWorkflowRuns) - r.Post("/workflows/runs/delete", s.deleteWorkflowRuns) - - r.Get("/watch", s.watchWS) -} - -func platformName() string { - switch runtime.GOOS { - case "darwin": - return "darwin" - case "windows": - return "win32" - default: - return "linux" - } -} - -func (s *Server) requireAuth(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - expected := strings.TrimSpace(cfg.AuthToken) - if expected == "" { - next.ServeHTTP(w, r) - return - } - - provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) - if subtleCompare(provided, expected) || s.requestAuthenticatedViaSession(r) { - next.ServeHTTP(w, r) - return - } - - if strings.HasSuffix(r.URL.Path, "/watch") || r.URL.Path == "/watch" { - if !s.wsRejectLimiter.allow(s.clientAddressKey(r)) { - http.Error(w, "too many unauthorized websocket attempts", http.StatusTooManyRequests) - return - } - } - - w.Header().Set("WWW-Authenticate", `Bearer realm="ZenNotes"`) - http.Error(w, "unauthorized", http.StatusUnauthorized) - }) -} - -// --- Responses --- - -func writeJSON(w http.ResponseWriter, code int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(v) -} - -func writeError(w http.ResponseWriter, err error) { - var statusErr httpStatusError - if errors.As(err, &statusErr) { - http.Error(w, statusErr.Error(), statusErr.code) - return - } - if errors.Is(err, vault.ErrPathEscape) { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if errors.Is(err, vault.ErrInvalidWorkflow) || errors.Is(err, vault.ErrInvalidTemplate) { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if errors.Is(err, vault.ErrWorkflowConflict) { - http.Error(w, err.Error(), http.StatusConflict) - return - } - // A missing file is the caller's answer, not our failure. Clients rely on - // this to tell "absent" apart from "broken": desktop remote databases map - // 404 to null and surface everything else. - if errors.Is(err, os.ErrNotExist) { - http.Error(w, "not found", http.StatusNotFound) - return - } - // Asking to read a directory as a file is a malformed request, not a - // server failure, and saying 500 sent one report chasing a broken server - // that was working correctly. - // - // The vault layer classifies this from its own stat, because the errno is - // not portable: reading a directory gives EISDIR on Unix and - // ERROR_INVALID_FUNCTION on Windows, which is how this answered 400 on - // macOS and Linux and 500 on Windows for two weeks. EISDIR stays as a - // fallback for read paths that have not been classified, where it is still - // right on the platforms that produce it. - if errors.Is(err, vault.ErrIsDirectory) || errors.Is(err, syscall.EISDIR) { - http.Error(w, "path is a directory, not a file", http.StatusBadRequest) - return - } - log.Printf("handler error: %v", err) - http.Error(w, "internal server error", http.StatusInternalServerError) -} - -// Error body for the vault-picker routes (/fs/browse, /vault/select). -// -// Every other route answers an error with plain text, which is fine because -// nothing has to tell those errors apart from anything else. These two do: a -// server that predates the routes answers with the router's own plain-text -// 404, and a current server answers a vanished directory with a 404 of its -// own. Only the JSON body distinguishes them, so the web client shows "that -// directory is gone" instead of "upgrade your server" (and vice versa). -type routeErrorBody struct { - Code string `json:"code"` - Message string `json:"message"` -} - -func writeCodedError(w http.ResponseWriter, err error) { - status := http.StatusInternalServerError - message := "internal server error" - var statusErr httpStatusError - switch { - case errors.As(err, &statusErr): - status, message = statusErr.code, statusErr.Error() - case errors.Is(err, vault.ErrPathEscape): - status, message = http.StatusBadRequest, err.Error() - case errors.Is(err, os.ErrNotExist): - status, message = http.StatusNotFound, err.Error() - default: - log.Printf("handler error: %v", err) - } - writeJSON(w, status, routeErrorBody{Code: errorCodeForStatus(status), Message: message}) -} - -func errorCodeForStatus(status int) string { - switch status { - case http.StatusNotFound: - return "not_found" - case http.StatusForbidden: - return "forbidden" - case http.StatusBadRequest: - return "bad_request" - case http.StatusConflict: - return "conflict" - default: - return "internal_error" - } -} - -func readJSON[T any](r *http.Request, out *T) error { - return json.NewDecoder(r.Body).Decode(out) -} - -// --- Handlers: meta --- - -func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) version(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ - "version": "0.1.0-web", - "go": runtime.Version(), - }) -} - -func (s *Server) capabilities(w http.ResponseWriter, _ *http.Request) { - cfg := s.currentConfig() - writeJSON(w, http.StatusOK, map[string]any{ - "version": "0.1.0-web", - "platform": platformName(), - "authRequired": strings.TrimSpace(cfg.AuthToken) != "", - "supportsSessionLogin": true, - "browseRootsEnforced": !cfg.AllowUnscopedBrowse, - "supportsVaultSelection": true, - "supportsDirectoryBrowsing": true, - // Honest, not aspirational: the watcher can be a no-op fallback - // (inotify-restricted hosts, ZENNOTES_DISABLE_WATCHER, #179), and a - // client that believes a dead feed never refreshes on its own. - "supportsWatch": s.currentWatcher().Active(), - // The full asset mutation family incl. the deleted-assets store - // (delete/duplicate/restore/purge). Desktop remote workspaces gate - // on this to give older servers a "server needs an update" message - // instead of a bare 404. - "supportsAssetOps": true, - // Workflow files and run journals live in the mounted vault, and the - // prepared-run endpoint applies them under the same vault lock as note - // writes. Its presence lets bundled web clients enable authoring and Run. - "supportsWorkflows": true, - // Custom-template CRUD under .zennotes/templates/ (the /templates - // routes), the same files the desktop keeps for a local vault. Absent - // before 2.46: the web client and a desktop on a remote vault hide New - // template and Edit there and say the server needs an update. - "supportsCustomTemplates": true, - // Says out loud that a missing file answers 404 rather than 500. - // Databases are composed from file reads where "absent" and "failed" - // mean opposite things (see remote-absence.ts), and a server that - // cannot say which is which forced clients to probe for the answer. - // Absent from every server before 2.20.2, which is exactly what makes - // it usable as a signal. - "reportsMissingAsNotFound": true, - }) -} - -func (s *Server) platform(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"platform": platformName()}) -} - -func (s *Server) vaultInfo(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, s.currentVault().Info()) -} - -func (s *Server) vaultSettings(w http.ResponseWriter, _ *http.Request) { - settings, err := s.currentVault().GetSettings() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, settings) -} - -func (s *Server) setVaultSettings(w http.ResponseWriter, r *http.Request) { - var req vault.VaultSettings - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - settings, err := s.currentVault().SetSettings(req) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, settings) -} - -func (s *Server) selectVault(w http.ResponseWriter, r *http.Request) { - if osPath := strings.TrimSpace(os.Getenv("ZENNOTES_VAULT_PATH")); osPath != "" { - http.Error(w, "vault path is managed by ZENNOTES_VAULT_PATH", http.StatusConflict) - return - } - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if strings.TrimSpace(req.Path) == "" { - http.Error(w, "vault path is required", http.StatusBadRequest) - return - } - allowedPath, err := s.ensureBrowsePathAllowed(req.Path) - if err != nil { - writeCodedError(w, err) - return - } - nextVault, err := s.switchVaultRoot(allowedPath) - if err != nil { - writeCodedError(w, err) - return - } - writeJSON(w, http.StatusOK, nextVault.Info()) -} - -type directoryBrowseEntry struct { - Name string `json:"name"` - Path string `json:"path"` -} - -type directoryBrowseShortcut struct { - Label string `json:"label"` - Path string `json:"path"` -} - -type directoryBrowseResult struct { - CurrentPath string `json:"currentPath"` - ParentPath *string `json:"parentPath"` - Entries []directoryBrowseEntry `json:"entries"` - Shortcuts []directoryBrowseShortcut `json:"shortcuts"` -} - -func appendBrowseShortcut(shortcuts []directoryBrowseShortcut, label string, path string) []directoryBrowseShortcut { - cleaned := strings.TrimSpace(path) - if cleaned == "" { - return shortcuts - } - for _, shortcut := range shortcuts { - if shortcut.Path == cleaned { - return shortcuts - } - } - if info, err := os.Stat(cleaned); err != nil || !info.IsDir() { - return shortcuts - } - return append(shortcuts, directoryBrowseShortcut{Label: label, Path: cleaned}) -} - -func browseRootLabel(path string, index int) string { - cleaned := filepath.Clean(path) - root := filesystemRootForPath(cleaned) - if cleaned == root { - return "Mounted Root" - } - base := filepath.Base(cleaned) - if base == "." || base == string(filepath.Separator) || strings.TrimSpace(base) == "" { - return "Mounted Root" - } - if index == 0 { - return base - } - return base -} - -func defaultBrowsePath() string { - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { - return home - } - if runtime.GOOS == "windows" { - return `C:\` - } - return string(filepath.Separator) -} - -func filesystemRootForPath(p string) string { - if volume := filepath.VolumeName(p); volume != "" { - return volume + string(filepath.Separator) - } - return string(filepath.Separator) -} - -func (s *Server) browseDirectories(w http.ResponseWriter, r *http.Request) { - requested := strings.TrimSpace(r.URL.Query().Get("path")) - target := requested - if target == "" { - target = s.defaultBrowsePath() - } - target, err := s.ensureBrowsePathAllowed(target) - if err != nil { - writeCodedError(w, err) - return - } - - dirEntries, err := os.ReadDir(target) - if err != nil { - writeCodedError(w, err) - return - } - - entries := make([]directoryBrowseEntry, 0, len(dirEntries)) - for _, entry := range dirEntries { - childPath := filepath.Join(target, entry.Name()) - childInfo, err := os.Stat(childPath) - if err != nil || !childInfo.IsDir() { - continue - } - if _, err := s.ensureBrowsePathAllowed(childPath); err != nil { - continue - } - entries = append(entries, directoryBrowseEntry{ - Name: entry.Name(), - Path: childPath, - }) - } - sort.Slice(entries, func(i, j int) bool { - left := strings.ToLower(entries[i].Name) - right := strings.ToLower(entries[j].Name) - if left == right { - return entries[i].Name < entries[j].Name - } - return left < right - }) - - parentPath := filepath.Dir(target) - var parent *string - if parentPath != "" && parentPath != target { - if allowedParent, err := s.ensureBrowsePathAllowed(parentPath); err == nil { - parent = &allowedParent - } - } - - writeJSON(w, http.StatusOK, directoryBrowseResult{ - CurrentPath: target, - ParentPath: parent, - Entries: entries, - Shortcuts: s.browseShortcuts(), - }) -} - -// --- Listing --- - -func (s *Server) listNotes(w http.ResponseWriter, _ *http.Request) { - notes, err := s.currentVault().ListNotes() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, notes) -} - -func (s *Server) listFolders(w http.ResponseWriter, _ *http.Request) { - folders, err := s.currentVault().ListFolders() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, folders) -} - -func (s *Server) listAssets(w http.ResponseWriter, _ *http.Request) { - assets, err := s.currentVault().ListAssets() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, assets) -} - -func (s *Server) assetsExists(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]bool{"exists": s.currentVault().HasAssetsDir()}) -} - -// --- Notes --- - -func (s *Server) readNote(w http.ResponseWriter, r *http.Request) { - rel := r.URL.Query().Get("path") - note, err := s.currentVault().ReadNote(rel) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, note) -} - -func (s *Server) readComments(w http.ResponseWriter, r *http.Request) { - rel := r.URL.Query().Get("path") - comments, err := s.currentVault().ReadNoteComments(rel) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, comments) -} - -func (s *Server) writeComments(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - Comments []vault.NoteComment `json:"comments"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - comments, err := s.currentVault().WriteNoteComments(req.Path, req.Comments) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, comments) -} - -func (s *Server) writeNote(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxNoteBytes+jsonEnvelopeBytes) - var req struct { - Path string `json:"path"` - Body string `json:"body"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().WriteNote(req.Path, req.Body) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) createNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - Title string `json:"title"` - Subpath string `json:"subpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().CreateNote(req.Folder, req.Title, req.Subpath) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) createExcalidraw(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - Title string `json:"title"` - Subpath string `json:"subpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().CreateExcalidraw(req.Folder, req.Title, req.Subpath) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) renameNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - Title string `json:"title"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().RenameNote(req.Path, req.Title) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().DeleteNote(req.Path); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) trashNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().MoveToTrash(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().RestoreFromTrash(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) emptyTrash(w http.ResponseWriter, _ *http.Request) { - if err := s.currentVault().EmptyTrash(); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) archiveNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().ArchiveNote(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) unarchiveNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().UnarchiveNote(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) duplicateNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().DuplicateNote(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) moveNote(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - TargetFolder vault.NoteFolder `json:"targetFolder"` - TargetSubpath string `json:"targetSubpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().MoveNote(req.Path, req.TargetFolder, req.TargetSubpath) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -// --- Folders --- - -func (s *Server) createFolder(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - Subpath string `json:"subpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().CreateFolder(req.Folder, req.Subpath); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) renameFolder(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - OldSubpath string `json:"oldSubpath"` - NewSubpath string `json:"newSubpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - out, err := s.currentVault().RenameFolder(req.Folder, req.OldSubpath, req.NewSubpath) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]string{"subpath": out}) -} - -func (s *Server) deleteFolder(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - Subpath string `json:"subpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().DeleteFolder(req.Folder, req.Subpath); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) duplicateFolder(w http.ResponseWriter, r *http.Request) { - var req struct { - Folder vault.NoteFolder `json:"folder"` - Subpath string `json:"subpath"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - out, err := s.currentVault().DuplicateFolder(req.Folder, req.Subpath) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]string{"subpath": out}) -} - -// --- Tasks + Search --- - -func (s *Server) allTasks(w http.ResponseWriter, r *http.Request) { - tasks, err := s.currentVault().ScanTasksWith(vault.ParseTasksOptions{ - IncludeExcluded: taskQueryIncludesExcluded(r), - }) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, tasks) -} - -func (s *Server) tasksFor(w http.ResponseWriter, r *http.Request) { - rel := r.URL.Query().Get("path") - tasks, err := s.currentVault().ScanTasksForPathWith(rel, vault.ParseTasksOptions{ - IncludeExcluded: taskQueryIncludesExcluded(r), - }) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, tasks) -} - -// taskQueryIncludesExcluded reads the ?includeExcluded= escape hatch (#458): -// scan past the vault's excluded-folders list and the note-level `tasks:` -// opt-out. Accepts the same truthy spellings the config loader does. -func taskQueryIncludesExcluded(r *http.Request) bool { - switch strings.ToLower(r.URL.Query().Get("includeExcluded")) { - case "1", "true", "yes", "on": - return true - } - return false -} - -func (s *Server) searchCapabilities(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, s.currentVault().SearchCapabilities()) -} - -func (s *Server) searchText(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query().Get("q") - matches, err := s.currentVault().SearchText(q) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, matches) -} - -// --- Demo tour --- - -func (s *Server) demoGenerate(w http.ResponseWriter, _ *http.Request) { - res, err := s.currentVault().GenerateDemoTour() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, res) -} - -func (s *Server) demoRemove(w http.ResponseWriter, _ *http.Request) { - res, err := s.currentVault().RemoveDemoTour() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, res) -} - -// --- Assets --- - -func (s *Server) rawAsset(w http.ResponseWriter, r *http.Request) { - rel := r.URL.Query().Get("path") - abs, err := s.currentVault().AssetAbsPath(rel) - if err != nil { - writeError(w, err) - return - } - ext := strings.ToLower(filepath.Ext(abs)) - if t := mime.TypeByExtension(ext); t != "" { - w.Header().Set("Content-Type", t) - } - w.Header().Set("Cache-Control", "private, max-age=3600") - http.ServeFile(w, r, abs) -} - -func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxAssetBytes+multipartOverheadBytes) - if err := r.ParseMultipartForm(8 << 20); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - notePath := r.FormValue("notePath") - file, header, err := r.FormFile("file") - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - defer file.Close() - asset, err := s.currentVault().ImportAsset(notePath, header.Filename, file) - if err != nil { - if errors.Is(err, vault.ErrAssetTooLarge) { - http.Error(w, "asset too large", http.StatusRequestEntityTooLarge) - return - } - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, asset) -} - -func (s *Server) renameAsset(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - Name string `json:"name"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().RenameAsset(req.Path, req.Name) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) moveAsset(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - TargetDir string `json:"targetDir"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().MoveAsset(req.Path, req.TargetDir) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) duplicateAsset(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().DuplicateAsset(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) deleteAsset(w http.ResponseWriter, r *http.Request) { - var req struct { - Path string `json:"path"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - deleted, err := s.currentVault().DeleteAsset(req.Path) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, deleted) -} - -func (s *Server) listDeletedAssets(w http.ResponseWriter, _ *http.Request) { - deleted, err := s.currentVault().ListDeletedAssets() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, deleted) -} - -func (s *Server) restoreDeletedAsset(w http.ResponseWriter, r *http.Request) { - var req vault.DeletedAsset - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - meta, err := s.currentVault().RestoreDeletedAsset(req) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) purgeDeletedAsset(w http.ResponseWriter, r *http.Request) { - var req struct { - UndoToken string `json:"undoToken"` - } - if err := readJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().PurgeDeletedAsset(req.UndoToken); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) emptyDeletedAssets(w http.ResponseWriter, _ *http.Request) { - if err := s.currentVault().EmptyDeletedAssets(); err != nil { - writeError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// --- WebSocket watcher --- - -// watchPingInterval is how often watchWS pings a subscriber to detect a dead -// peer. A var, not a const, so the regression test can shrink it and prove -// events survive ping cycles without waiting out real 25-second ticks. -var watchPingInterval = 25 * time.Second - -func (s *Server) watchWS(w http.ResponseWriter, r *http.Request) { - origin := strings.TrimSpace(r.Header.Get("Origin")) - if origin != "" && !s.isAllowedOrigin(r, origin) { - if !s.wsRejectLimiter.allow(s.clientAddressKey(r)) { - http.Error(w, "too many invalid websocket origins", http.StatusTooManyRequests) - return - } - http.Error(w, "forbidden origin", http.StatusForbidden) - return - } - ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - InsecureSkipVerify: true, - }) - if err != nil { - log.Printf("ws accept failed: %v", err) - return - } - defer ws.Close(websocket.StatusNormalClosure, "") - // This connection is write-only, but the library only processes incoming - // control frames during a read. Without CloseRead the client's pong is - // never seen, so the first keepalive Ping below blocked forever and the - // subscriber went silent 25 seconds after connecting — the "changes don't - // appear until I refresh" report in the flesh. CloseRead spawns the reader - // that keeps pings honest and cancels the context when the peer goes away. - ctx := ws.CloseRead(r.Context()) - - events, unsubscribe := s.currentWatcher().Subscribe() - defer unsubscribe() - - pingTicker := time.NewTicker(watchPingInterval) - defer pingTicker.Stop() - - for { - select { - case <-ctx.Done(): - return - case ev, ok := <-events: - if !ok { - return - } - payload, _ := json.Marshal(ev) - if err := ws.Write(ctx, websocket.MessageText, payload); err != nil { - return - } - case <-pingTicker.C: - if err := ws.Ping(ctx); err != nil { - return - } - } - } -} - -// --- Static / PWA fallback --- - -func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) { - // chi's Mount routes by a stripped path but leaves r.URL.Path intact, - // so under a base-path deploy this still carries the prefix (e.g. - // "/zennotes/assets/app.css"). Trim it before resolving against the - // embedded bundle, otherwise every asset misses and falls back to - // index.html with a text/html MIME type (issue #58). - urlPath := r.URL.Path - if basePath := s.currentConfig().BasePath; basePath != "" { - urlPath = strings.TrimPrefix(urlPath, basePath) - } - urlPath = strings.TrimPrefix(urlPath, "/") - if urlPath == "" { - urlPath = "index.html" - } - f, err := s.Static.Open(urlPath) - if err != nil { - // SPA fallback: serve index.html for unknown paths. - s.serveIndexHTML(w) - return - } - defer f.Close() - if urlPath == "index.html" { - s.serveIndexHTML(w) - return - } - ext := strings.ToLower(filepath.Ext(urlPath)) - if t := mime.TypeByExtension(ext); t != "" { - w.Header().Set("Content-Type", t) - } - _, _ = copyReadSeeker(w, f) -} - -// serveIndexHTML reads the SPA shell from the embedded bundle and -// returns it with a small runtime patch so the JS bundle knows which -// base path to use for API + WebSocket calls. -func (s *Server) serveIndexHTML(w http.ResponseWriter) { - f, err := s.Static.Open("index.html") - if err != nil { - http.NotFound(w, nil) - return - } - defer f.Close() - - body, err := readAll(f) - if err != nil { - http.NotFound(w, nil) - return - } - - basePath := s.currentConfig().BasePath - if basePath != "" { - body = injectBasePathHint(body, basePath) - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache") - _, _ = w.Write(body) -} - -func readAll(f fs.File) ([]byte, error) { - buf := make([]byte, 0, 4*1024) - tmp := make([]byte, 4*1024) - for { - n, err := f.Read(tmp) - if n > 0 { - buf = append(buf, tmp[:n]...) - } - if err != nil { - if err.Error() == "EOF" { - return buf, nil - } - return nil, err - } - } -} - -// injectBasePathHint splices a `` tag into -// the SPA shell so the bundled JS can route API calls through the -// configured prefix. A meta tag (instead of an inline script) keeps us -// inside the strict CSP — script-src is locked to 'self'. -func injectBasePathHint(body []byte, basePath string) []byte { - snippet := []byte(``) - if idx := indexOfFold(body, []byte("")); idx >= 0 { - out := make([]byte, 0, len(body)+len(snippet)) - out = append(out, body[:idx]...) - out = append(out, snippet...) - out = append(out, body[idx:]...) - return out - } - return append(snippet, body...) -} - -// htmlAttrEscape escapes the characters that would let a base-path -// value break out of a double-quoted HTML attribute. -func htmlAttrEscape(value string) string { - replacer := strings.NewReplacer( - "&", "&", - "\"", """, - "<", "<", - ">", ">", - ) - return replacer.Replace(value) -} - -func indexOfFold(haystack, needle []byte) int { - n := len(needle) - if n == 0 || n > len(haystack) { - return -1 - } - for i := 0; i+n <= len(haystack); i++ { - match := true - for j := 0; j < n; j++ { - a := haystack[i+j] - b := needle[j] - if a >= 'A' && a <= 'Z' { - a += 'a' - 'A' - } - if b >= 'A' && b <= 'Z' { - b += 'a' - 'A' - } - if a != b { - match = false - break - } - } - if match { - return i - } - } - return -1 -} - -func copyReadSeeker(w http.ResponseWriter, f fs.File) (int64, error) { - if rs, ok := f.(interface { - Read(p []byte) (int, error) - }); ok { - buf := make([]byte, 32*1024) - var total int64 - for { - n, err := rs.Read(buf) - if n > 0 { - if _, werr := w.Write(buf[:n]); werr != nil { - return total, werr - } - total += int64(n) - } - if err != nil { - if err.Error() == "EOF" { - return total, nil - } - return total, err - } - } - } - return 0, nil -} diff --git a/apps/server/internal/httpserver/templates.go b/apps/server/internal/httpserver/templates.go deleted file mode 100644 index 3ba65fd4..00000000 --- a/apps/server/internal/httpserver/templates.go +++ /dev/null @@ -1,77 +0,0 @@ -package httpserver - -import ( - "errors" - "net/http" - - "github.com/ZenNotes/zennotes/apps/server/internal/vault" -) - -// Custom-template routes: the server half of Settings, Templates for the web -// client and for a desktop connected to a remote vault. Clients gate on the -// supportsCustomTemplates capability, so an older server answers a bare 404 -// here and they say the server needs an update instead. - -const maxTemplateMetadataRequestBytes = 64 << 10 - -func (s *Server) listTemplates(w http.ResponseWriter, _ *http.Request) { - files, err := s.currentVault().ListTemplates() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, files) -} - -func (s *Server) readTemplate(w http.ResponseWriter, r *http.Request) { - raw, err := s.currentVault().ReadTemplate(r.URL.Query().Get("path")) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]string{"raw": raw}) -} - -func (s *Server) writeTemplate(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxNoteBytes+jsonEnvelopeBytes) - var input vault.WriteTemplateInput - if err := readJSON(r, &input); err != nil { - var tooLarge *http.MaxBytesError - if errors.As(err, &tooLarge) { - http.Error(w, "template exceeds the configured note size limit", http.StatusRequestEntityTooLarge) - return - } - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - // The envelope allowance above is for field names and JSON escaping, not - // for the template: a body that fits the reader can still unescape to a - // raw string past the note limit, and a template is a note in waiting. - if cfg.MaxNoteBytes > 0 && int64(len(input.Raw)) > cfg.MaxNoteBytes { - http.Error(w, "template exceeds the configured note size limit", http.StatusRequestEntityTooLarge) - return - } - file, err := s.currentVault().WriteTemplate(input) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, file) -} - -func (s *Server) deleteTemplate(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxTemplateMetadataRequestBytes) - var request struct { - SourcePath string `json:"sourcePath"` - } - if err := readJSON(r, &request); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().DeleteTemplate(request.SourcePath); err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) -} diff --git a/apps/server/internal/httpserver/templates_test.go b/apps/server/internal/httpserver/templates_test.go deleted file mode 100644 index 9a6c4266..00000000 --- a/apps/server/internal/httpserver/templates_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package httpserver - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" -) - -const templateTestToken = "template-token" - -func templateTestServer(t *testing.T, maxNoteBytes int64) (*httptest.Server, string) { - t.Helper() - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "Note.md"), []byte("# Note\n"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: templateTestToken, - BrowseRoots: []string{root}, - MaxNoteBytes: maxNoteBytes, - }) - return server, root -} - -func templateRequest(t *testing.T, method, url string, body any, token string) *http.Response { - t.Helper() - var reader io.Reader - if body != nil { - raw, err := json.Marshal(body) - if err != nil { - t.Fatal(err) - } - reader = bytes.NewReader(raw) - } - req, err := http.NewRequest(method, url, reader) - if err != nil { - t.Fatal(err) - } - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("%s %s: %v", method, url, err) - } - return resp -} - -func decodeBody[T any](t *testing.T, resp *http.Response) T { - t.Helper() - defer resp.Body.Close() - var out T - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - t.Fatalf("decode: %v", err) - } - return out -} - -func TestTemplateRoutesRequireAuth(t *testing.T) { - server, _ := templateTestServer(t, 10<<20) - for _, route := range []struct{ method, path string }{ - {http.MethodGet, "/api/templates"}, - {http.MethodGet, "/api/templates/read?path=.zennotes/templates/adr.md"}, - {http.MethodPost, "/api/templates/write"}, - {http.MethodPost, "/api/templates/delete"}, - } { - resp := templateRequest(t, route.method, server.URL+route.path, map[string]string{}, "") - resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("%s %s without a token: %d, want 401", route.method, route.path, resp.StatusCode) - } - } - resp := templateRequest(t, http.MethodGet, server.URL+"/api/templates", nil, templateTestToken) - if resp.StatusCode != http.StatusOK { - t.Fatalf("list with a token: %d", resp.StatusCode) - } - if files := decodeBody[[]vault.CustomTemplateFile](t, resp); len(files) != 0 { - t.Fatalf("fresh vault lists %+v", files) - } -} - -func TestTemplateRoutesWriteListReadDelete(t *testing.T) { - server, root := templateTestServer(t, 10<<20) - raw := "---\nname: Weekly\n---\n# {{title}}\n" - - resp := templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ - "slug": "Réunion Hebdo!", "raw": raw, - }, templateTestToken) - if resp.StatusCode != http.StatusOK { - t.Fatalf("write: %d", resp.StatusCode) - } - written := decodeBody[vault.CustomTemplateFile](t, resp) - if written.SourcePath != ".zennotes/templates/r-union-hebdo.md" || written.Raw != raw { - t.Fatalf("written = %+v", written) - } - if body, err := os.ReadFile(filepath.Join(root, ".zennotes", "templates", "r-union-hebdo.md")); err != nil || string(body) != raw { - t.Fatalf("file on disk: %q (%v)", body, err) - } - - files := decodeBody[[]vault.CustomTemplateFile](t, templateRequest(t, http.MethodGet, server.URL+"/api/templates", nil, templateTestToken)) - if len(files) != 1 || files[0].SourcePath != written.SourcePath || files[0].Raw != raw { - t.Fatalf("list = %+v", files) - } - - read := decodeBody[map[string]string](t, templateRequest(t, http.MethodGet, server.URL+"/api/templates/read?path="+written.SourcePath, nil, templateTestToken)) - if read["raw"] != raw { - t.Fatalf("read = %+v", read) - } - - renamed := decodeBody[vault.CustomTemplateFile](t, templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ - "slug": "weekly", "raw": raw + "\nmore", "previousSourcePath": written.SourcePath, - }, templateTestToken)) - if renamed.SourcePath != ".zennotes/templates/weekly.md" { - t.Fatalf("renamed = %+v", renamed) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "r-union-hebdo.md")); !os.IsNotExist(err) { - t.Fatalf("rename left the old file: %v", err) - } - - del := templateRequest(t, http.MethodPost, server.URL+"/api/templates/delete", map[string]string{"sourcePath": renamed.SourcePath}, templateTestToken) - del.Body.Close() - if del.StatusCode != http.StatusOK { - t.Fatalf("delete: %d", del.StatusCode) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "weekly.md")); !os.IsNotExist(err) { - t.Fatalf("delete left the file: %v", err) - } - gone := templateRequest(t, http.MethodGet, server.URL+"/api/templates/read?path="+renamed.SourcePath, nil, templateTestToken) - gone.Body.Close() - if gone.StatusCode != http.StatusNotFound { - t.Fatalf("read after delete: %d, want 404", gone.StatusCode) - } -} - -func TestTemplateRoutesRejectUnsafePaths(t *testing.T) { - server, root := templateTestServer(t, 10<<20) - for _, path := range []string{ - "../../etc/passwd", - ".zennotes/templates/../../inbox/Note.md", - ".zennotes/templates/sub/dir.md", - ".zennotes/templates/not-markdown.txt", - "inbox/Note.md", - } { - resp := templateRequest(t, http.MethodGet, server.URL+"/api/templates/read?path="+path, nil, templateTestToken) - resp.Body.Close() - if resp.StatusCode != http.StatusBadRequest { - t.Errorf("read %q: %d, want 400", path, resp.StatusCode) - } - del := templateRequest(t, http.MethodPost, server.URL+"/api/templates/delete", map[string]string{"sourcePath": path}, templateTestToken) - del.Body.Close() - if del.StatusCode != http.StatusBadRequest { - t.Errorf("delete %q: %d, want 400", path, del.StatusCode) - } - } - if _, err := os.Stat(filepath.Join(root, "inbox", "Note.md")); err != nil { - t.Fatalf("a template delete reached a note: %v", err) - } -} - -func TestTemplateWriteRespectsNoteSizeLimit(t *testing.T) { - server, root := templateTestServer(t, 64) - // Past the note limit but inside the JSON envelope allowance: the explicit - // check answers. - resp := templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ - "slug": "big", "raw": strings.Repeat("x", 200), - }, templateTestToken) - resp.Body.Close() - if resp.StatusCode != http.StatusRequestEntityTooLarge { - t.Fatalf("200-byte template with a 64-byte limit: %d, want 413", resp.StatusCode) - } - // Past the reader itself. - resp = templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ - "slug": "huge", "raw": strings.Repeat("x", 70<<10), - }, templateTestToken) - resp.Body.Close() - if resp.StatusCode != http.StatusRequestEntityTooLarge { - t.Fatalf("70 KiB template with a 64-byte limit: %d, want 413", resp.StatusCode) - } - if entries, _ := os.ReadDir(filepath.Join(root, ".zennotes", "templates")); len(entries) != 0 { - t.Fatalf("rejected writes left files: %v", entries) - } - ok := templateRequest(t, http.MethodPost, server.URL+"/api/templates/write", map[string]string{ - "slug": "small", "raw": "fits", - }, templateTestToken) - ok.Body.Close() - if ok.StatusCode != http.StatusOK { - t.Fatalf("small template: %d", ok.StatusCode) - } -} - -func TestCapabilitiesAdvertiseCustomTemplateSupport(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - BrowseRoots: []string{root}, - }) - resp, err := http.Get(server.URL + "/api/capabilities") - if err != nil { - t.Fatal(err) - } - caps := decodeBody[map[string]any](t, resp) - if caps["supportsCustomTemplates"] != true { - t.Fatalf("supportsCustomTemplates = %v, want true", caps["supportsCustomTemplates"]) - } -} diff --git a/apps/server/internal/httpserver/watch_ws_test.go b/apps/server/internal/httpserver/watch_ws_test.go deleted file mode 100644 index 9715e21c..00000000 --- a/apps/server/internal/httpserver/watch_ws_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package httpserver - -import ( - "context" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" - "github.com/ZenNotes/zennotes/apps/server/internal/vault" - "github.com/ZenNotes/zennotes/apps/server/internal/watcher" - "github.com/coder/websocket" - "net/http/httptest" -) - -// TestWatchSubscriberSurvivesPingCycles is the regression test for the wedge -// that produced "changes don't appear until I refresh": watchWS never read -// from the connection, so the client's pong was never processed and the first -// keepalive Ping blocked forever — every subscriber went silent 25 seconds -// after connecting while the connection stayed ESTABLISHED. Here the ping -// interval is shrunk so several cycles pass in milliseconds, then a file -// change must still reach the subscriber. -func TestWatchSubscriberSurvivesPingCycles(t *testing.T) { - oldInterval := watchPingInterval - watchPingInterval = 30 * time.Millisecond - defer func() { watchPingInterval = oldInterval }() - - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - v, err := vault.New(root, vault.Options{}) - if err != nil { - t.Fatal(err) - } - w := watcher.StartOrDisabled(root, false) - if !w.Active() { - t.Skip("filesystem watching unavailable in this environment") - } - t.Cleanup(w.Close) - - cfg := config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - } - server := httptest.NewServer(New(v, w, nil, cfg).Router()) - t.Cleanup(server.Close) - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/watch" - conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{ - HTTPHeader: http.Header{"Authorization": []string{"Bearer secret-token"}}, - }) - if err != nil { - t.Fatalf("dial watch socket: %v", err) - } - defer conn.Close(websocket.StatusNormalClosure, "") - - // Let well over a handful of ping cycles pass. The wedged loop hung on - // the very first one. - time.Sleep(300 * time.Millisecond) - - if err := os.WriteFile(filepath.Join(root, "inbox", "Late.md"), []byte("# Late"), 0o600); err != nil { - t.Fatal(err) - } - - // Read pumps control frames client-side too, so this both answers any - // in-flight ping and receives the change event. - _, payload, err := conn.Read(ctx) - if err != nil { - t.Fatalf("no event after ping cycles (the pre-fix wedge): %v", err) - } - if !strings.Contains(string(payload), "Late.md") { - t.Fatalf("event payload = %s, want the Late.md change", payload) - } -} diff --git a/apps/server/internal/httpserver/workflows.go b/apps/server/internal/httpserver/workflows.go deleted file mode 100644 index 06affa91..00000000 --- a/apps/server/internal/httpserver/workflows.go +++ /dev/null @@ -1,128 +0,0 @@ -package httpserver - -import ( - "net/http" - "strings" - - "github.com/ZenNotes/zennotes/apps/server/internal/vault" -) - -const ( - maxWorkflowRequestBytes = 128 << 20 - maxWorkflowMetadataRequestBytes = 64 << 10 -) - -func (s *Server) listWorkflows(w http.ResponseWriter, _ *http.Request) { - files, err := s.currentVault().ListWorkflows() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, files) -} - -func (s *Server) writeWorkflow(w http.ResponseWriter, r *http.Request) { - cfg := s.currentConfig() - r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxNoteBytes+jsonEnvelopeBytes) - var input vault.WriteWorkflowInput - if err := readJSON(r, &input); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - file, err := s.currentVault().WriteWorkflow(input) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, file) -} - -func (s *Server) deleteWorkflow(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowMetadataRequestBytes) - var request struct { - SourcePath string `json:"sourcePath"` - } - if err := readJSON(r, &request); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := s.currentVault().DeleteWorkflow(request.SourcePath); err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) -} - -func (s *Server) applyWorkflow(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowRequestBytes) - var input vault.PreparedWorkflowRun - if err := readJSON(r, &input); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - cfg := s.currentConfig() - for _, change := range input.Changes { - // Only what the run WRITES counts against the limit. Before is the - // note's bytes already on disk: counting those made an oversized note - // impossible to shrink, move, or trash from the web client, 413 on - // every apply, while desktop applied the identical run. - if cfg.MaxNoteBytes > 0 && change.After != nil && int64(len(*change.After)) > cfg.MaxNoteBytes { - http.Error(w, "workflow note exceeds the configured note size limit", http.StatusRequestEntityTooLarge) - return - } - } - receipt, err := s.currentVault().ApplyPreparedWorkflow(input) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, receipt) -} - -func (s *Server) undoWorkflowRun(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowMetadataRequestBytes) - var request struct { - RunID string `json:"runId"` - } - if err := readJSON(r, &request); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - result, err := s.currentVault().UndoWorkflowRun(request.RunID) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, result) -} - -func (s *Server) listWorkflowRuns(w http.ResponseWriter, _ *http.Request) { - runs, err := s.currentVault().ListWorkflowRuns() - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, runs) -} - -func (s *Server) deleteWorkflowRuns(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowMetadataRequestBytes) - var request struct { - WorkflowID string `json:"workflowId"` - } - if err := readJSON(r, &request); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - workflowID := strings.TrimSpace(request.WorkflowID) - if workflowID == "" { - http.Error(w, "workflowId is required", http.StatusBadRequest) - return - } - removed, err := s.currentVault().DeleteWorkflowRuns(workflowID) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, removed) -} diff --git a/apps/server/internal/httpserver/workflows_test.go b/apps/server/internal/httpserver/workflows_test.go deleted file mode 100644 index f28ab47a..00000000 --- a/apps/server/internal/httpserver/workflows_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package httpserver - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "os" - "path/filepath" - "testing" - - "github.com/ZenNotes/zennotes/apps/server/internal/config" -) - -// The Docker image serves the web client and owns the mounted vault. Workflow -// authoring, execution and Undo therefore have to cross the HTTP boundary and -// persist inside that mounted vault rather than being treated as desktop-only. -func TestWorkflowEndpointsAuthorApplyAndUndo(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - - post := func(path string, payload any) *http.Response { - t.Helper() - body, err := json.Marshal(payload) - if err != nil { - t.Fatal(err) - } - resp, err := client.Post(server.URL+path, "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST %s: %v", path, err) - } - return resp - } - requireOK := func(resp *http.Response) { - t.Helper() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - t.Fatalf("%s %s: got %d: %s", resp.Request.Method, resp.Request.URL.Path, resp.StatusCode, body) - } - } - - workflowRaw := "---\nname: Docker workflow\nstatus: active\n---\n\nall | append done\n" - writeResp := post("/api/workflows/write", map[string]any{ - "slug": "Docker workflow", - "raw": workflowRaw, - }) - requireOK(writeResp) - var written struct { - ID string `json:"id"` - SourcePath string `json:"sourcePath"` - Raw string `json:"raw"` - } - if err := json.NewDecoder(writeResp.Body).Decode(&written); err != nil { - t.Fatal(err) - } - writeResp.Body.Close() - if written.ID != "docker-workflow" || written.SourcePath != ".zennotes/workflows/docker-workflow.md" || written.Raw != workflowRaw { - t.Fatalf("written workflow = %+v", written) - } - - listResp, err := client.Get(server.URL + "/api/workflows") - if err != nil { - t.Fatal(err) - } - requireOK(listResp) - var listed []map[string]any - if err := json.NewDecoder(listResp.Body).Decode(&listed); err != nil { - t.Fatal(err) - } - listResp.Body.Close() - if len(listed) != 1 || listed[0]["id"] != "docker-workflow" { - t.Fatalf("listed workflows = %#v", listed) - } - - applyResp := post("/api/workflows/apply", map[string]any{ - "workflowId": "docker-workflow", - "ops": []any{map[string]any{"kind": "append", "path": "inbox/A.md", "text": "done"}}, - "applied": 1, - "irreversible": 0, - "changes": []any{map[string]any{ - "path": "inbox/A.md", - "before": "# A\n", - "after": "# A\n\ndone", - }}, - }) - requireOK(applyResp) - var receipt struct { - RunID string `json:"runId"` - WorkflowID string `json:"workflowId"` - Applied int `json:"applied"` - Paths []string `json:"paths"` - } - if err := json.NewDecoder(applyResp.Body).Decode(&receipt); err != nil { - t.Fatal(err) - } - applyResp.Body.Close() - if receipt.RunID == "" || receipt.WorkflowID != "docker-workflow" || receipt.Applied != 1 || len(receipt.Paths) != 1 { - t.Fatalf("receipt = %+v", receipt) - } - if body, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(body) != "# A\n\ndone" { - t.Fatalf("applied note = %q, %v", body, err) - } - - runsResp, err := client.Get(server.URL + "/api/workflows/runs") - if err != nil { - t.Fatal(err) - } - requireOK(runsResp) - var runs []struct { - RunID string `json:"runId"` - Undoable bool `json:"undoable"` - } - if err := json.NewDecoder(runsResp.Body).Decode(&runs); err != nil { - t.Fatal(err) - } - runsResp.Body.Close() - if len(runs) != 1 || runs[0].RunID != receipt.RunID || !runs[0].Undoable { - t.Fatalf("runs = %+v", runs) - } - - undoResp := post("/api/workflows/undo", map[string]string{"runId": receipt.RunID}) - requireOK(undoResp) - undoResp.Body.Close() - if body, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(body) != "# A\n" { - t.Fatalf("undone note = %q, %v", body, err) - } - - deleteResp := post("/api/workflows/delete", map[string]string{"sourcePath": written.SourcePath}) - requireOK(deleteResp) - deleteResp.Body.Close() - if _, err := os.Stat(filepath.Join(root, ".zennotes", "workflows", "docker-workflow.md")); !os.IsNotExist(err) { - t.Fatalf("workflow still exists after delete: %v", err) - } -} - -func TestCapabilitiesAdvertiseWorkflowSupport(t *testing.T) { - root := t.TempDir() - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - BrowseRoots: []string{root}, - }) - resp, err := http.Get(server.URL + "/api/capabilities") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - var caps map[string]any - if err := json.NewDecoder(resp.Body).Decode(&caps); err != nil { - t.Fatal(err) - } - if caps["supportsWorkflows"] != true { - t.Fatalf("supportsWorkflows = %v, want true", caps["supportsWorkflows"]) - } -} - -func TestApplyWorkflowRespectsPerNoteSizeLimit(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { - t.Fatal(err) - } - server, _ := newTestServer(t, config.Config{ - VaultPath: root, - DefaultVaultPath: root, - Bind: "127.0.0.1:7878", - AuthToken: "secret-token", - BrowseRoots: []string{root}, - MaxNoteBytes: 8, - }) - jar := loginAndJar(t, server, "secret-token") - client := &http.Client{Jar: jar} - body, err := json.Marshal(map[string]any{ - "workflowId": "oversized", - "ops": []any{map[string]any{"kind": "write-note", "path": "inbox/A.md", "text": "this is too large"}}, - "applied": 1, - "irreversible": 0, - "changes": []any{map[string]any{ - "path": "inbox/A.md", - "before": "# A\n", - "after": "this is too large", - }}, - }) - if err != nil { - t.Fatal(err) - } - resp, err := client.Post(server.URL+"/api/workflows/apply", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusRequestEntityTooLarge { - responseBody, _ := io.ReadAll(resp.Body) - t.Fatalf("oversized workflow note: got %d: %s", resp.StatusCode, responseBody) - } - if got, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(got) != "# A\n" { - t.Fatalf("oversized workflow changed note to %q (%v)", got, err) - } -} diff --git a/apps/server/internal/vault/asset_link_rename.go b/apps/server/internal/vault/asset_link_rename.go deleted file mode 100644 index 96cbc032..00000000 --- a/apps/server/internal/vault/asset_link_rename.go +++ /dev/null @@ -1,401 +0,0 @@ -package vault - -import ( - "net/url" - "regexp" - "sort" - "strings" -) - -// Rewriting references to an asset when the asset file is renamed or moved -// (#785). Port of packages/shared-domain/src/asset-link-rename.ts and of the -// resolver in asset-path-resolution.ts: a reference resolves relative to its -// note, then to the vault root, then by unique basename (the renderer's three -// readings); the ones that resolve to the asset are re-targeted in the -// author's own style (relative stays relative, rooted stays rooted, a bare -// name stays bare while unique) and keep everything else: `|alias` / `|300` -// hints, `#page=3` fragments, percent-encoding, angle brackets, link titles. - -var ( - assetWikilinkRe = regexp.MustCompile(`(!?)\[\[([^\]\n]+?)\]\]`) - // Matched from the `](` so the href of an image nested inside a link - // (`[![alt](a.png)](a.png)`) is found as readily as the outer link's own. - assetMdDestRe = regexp.MustCompile(`\]\(\s*(<[^>\n]*>|[^)\n]+?)((?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?)\s*\)`) -) - -func assetStripQueryAndHash(href string) string { - if i := strings.IndexByte(href, '#'); i >= 0 { - href = href[:i] - } - if i := strings.IndexByte(href, '?'); i >= 0 { - href = href[:i] - } - return href -} - -func assetDecodeHref(value string) string { - cleaned := assetStripQueryAndHash(value) - if decoded, err := url.PathUnescape(cleaned); err == nil { - return decoded - } - return cleaned -} - -func posixJoin(a, b string) string { - switch { - case a == "": - return b - case b == "": - return a - case strings.HasSuffix(a, "/"): - return a + b - } - return a + "/" + b -} - -func posixNormalize(input string) string { - out := []string{} - for _, part := range strings.Split(input, "/") { - switch part { - case "", ".": - continue - case "..": - if len(out) == 0 { - return ".." - } - out = out[:len(out)-1] - default: - out = append(out, part) - } - } - return strings.Join(out, "/") -} - -func lastPathSegment(p string) string { - parts := strings.Split(p, "/") - for i := len(parts) - 1; i >= 0; i-- { - if parts[i] != "" { - return parts[i] - } - } - return "" -} - -// Which of the three readings resolved a reference. Lets a rewrite keep the -// author's style: a note-relative href stays relative, a vault-root path stays -// rooted, a bare file name stays bare. -const ( - assetReadingNoteRelative = "note-relative" - assetReadingVaultRoot = "vault-root" - assetReadingBasename = "basename" -) - -type assetReferenceResolution struct { - path string - reading string - absolute bool // written with a leading `/` -} - -// resolveAssetPathAmong mirrors the renderer's resolveAssetPathAmong: the -// vault-relative path of the existing asset an href or wikilink target points -// at, or false when it points nowhere (or at more than one file by basename). -func resolveAssetPathAmong(assets []AssetMeta, notePath, href string) (string, bool) { - r, ok := resolveAssetReference(assets, notePath, href) - if !ok { - return "", false - } - return r.path, true -} - -// resolveAssetReference mirrors the renderer's resolveAssetReference: the -// resolved path plus the reading that found it. -func resolveAssetReference(assets []AssetMeta, notePath, href string) (assetReferenceResolution, bool) { - none := assetReferenceResolution{} - trimmed := strings.TrimSpace(href) - if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") { - return none, false - } - if schemeRe.MatchString(trimmed) { - return none, false - } - noteDir := "" - if i := strings.LastIndexByte(notePath, '/'); i >= 0 { - noteDir = notePath[:i] - } - decoded := assetDecodeHref(trimmed) - isAbs := strings.HasPrefix(decoded, "/") - var target string - switch { - case isAbs: - target = strings.TrimLeft(decoded, "/") - case noteDir != "": - target = posixJoin(noteDir, decoded) - default: - target = decoded - } - target = posixNormalize(target) - if strings.HasPrefix(target, "../") || target == ".." { - return none, false - } - has := func(p string) bool { - for _, a := range assets { - if a.Path == p { - return true - } - } - return false - } - if has(target) { - reading := assetReadingNoteRelative - if isAbs || noteDir == "" { - reading = assetReadingVaultRoot - } - return assetReferenceResolution{path: target, reading: reading, absolute: isAbs}, true - } - if !isAbs && noteDir != "" { - rootTarget := posixNormalize(decoded) - if rootTarget != "" && rootTarget != target && !strings.HasPrefix(rootTarget, "../") && - rootTarget != ".." && has(rootTarget) { - return assetReferenceResolution{path: rootTarget, reading: assetReadingVaultRoot}, true - } - } - base := strings.ToLower(lastPathSegment(target)) - if base == "" { - return none, false - } - match, count := "", 0 - for _, a := range assets { - if strings.ToLower(lastPathSegment(a.Path)) == base { - match = a.Path - count++ - } - } - if count == 1 { - return assetReferenceResolution{path: match, reading: assetReadingBasename}, true - } - return none, false -} - -// assetRelativeTo is the POSIX path from directory fromDir ("" = vault root) to toPath. -func assetRelativeTo(fromDir, toPath string) string { - from := splitNonEmpty(fromDir) - to := splitNonEmpty(toPath) - shared := 0 - for shared < len(from) && shared < len(to) && from[shared] == to[shared] { - shared++ - } - parts := make([]string, 0, len(from)-shared+len(to)-shared) - for i := shared; i < len(from); i++ { - parts = append(parts, "..") - } - parts = append(parts, to[shared:]...) - return strings.Join(parts, "/") -} - -func splitNonEmpty(p string) []string { - out := []string{} - for _, part := range strings.Split(p, "/") { - if part != "" { - out = append(out, part) - } - } - return out -} - -// assetEncodeLike percent-encodes next per segment when the author wrote -// original encoded. -func assetEncodeLike(original, next string) string { - decoded, err := url.PathUnescape(original) - if err != nil || decoded == original { - return next - } - segments := strings.Split(next, "/") - for i, seg := range segments { - if seg != ".." && seg != "." { - segments[i] = url.PathEscape(seg) - } - } - return strings.Join(segments, "/") -} - -// assetRetarget rewrites one reference (a wikilink target or markdown href) -// that resolved via resolution so it points at newPath, in the author's style, -// keeping angle brackets, `#`/`?` suffix and percent-encoding. Wikilinks and -// hrefs differ in one place: a wikilink is written as a bare name or a -// vault-root path (Obsidian resolves them from the root), never with `..`, so -// a wikilink that happened to resolve next to its note stays bare while unique -// and otherwise gets the full path; a markdown href is a real relative path, -// so it follows the file with `..` where needed. -func assetRetarget(reference string, resolution assetReferenceResolution, noteDir, newPath string, bareStillUnique, wikilink bool) string { - angled := strings.HasPrefix(reference, "<") && strings.HasSuffix(reference, ">") - inner := reference - if angled { - inner = reference[1 : len(reference)-1] - } - suffix := "" - if i := strings.IndexAny(inner, "#?"); i >= 0 { - suffix = inner[i:] - inner = inner[:i] - } - bare := !strings.Contains(inner, "/") - var next string - switch { - case resolution.reading == assetReadingBasename || (wikilink && bare): - if bareStillUnique { - next = lastPathSegment(newPath) - } else { - next = newPath - } - case resolution.reading == assetReadingNoteRelative && !wikilink: - next = assetRelativeTo(noteDir, newPath) - default: - next = newPath - if resolution.absolute { - next = "/" + newPath - } - } - out := assetEncodeLike(inner, next) + suffix - if angled { - return "<" + out + ">" - } - return out -} - -// rewriteAssetReferencesInBody rewrites every reference in body that resolves -// to the asset at oldPath so it points at newPath (vault-relative; a rename -// changes the name, a move the directory). assets must be the pre-change -// listing so references resolve to the asset under the path they currently -// use; notePath is the note holding body, since a markdown href resolves -// relative to it. Code is skipped. -func rewriteAssetReferencesInBody(body string, assets []AssetMeta, notePath, oldPath, newPath string) (string, int) { - if newPath == "" || oldPath == newPath { - return body, 0 - } - if !strings.Contains(body, "[[") && !strings.Contains(body, "](") { - return body, 0 - } - noteDir := "" - if i := strings.LastIndexByte(notePath, '/'); i >= 0 { - noteDir = notePath[:i] - } - newBase := strings.ToLower(lastPathSegment(newPath)) - sameBase := 0 - for _, a := range assets { - p := a.Path - if p == oldPath { - p = newPath - } - if strings.ToLower(lastPathSegment(p)) == newBase { - sameBase++ - } - } - bareStillUnique := sameBase == 1 - resolveOld := func(reference string) (assetReferenceResolution, bool) { - t := strings.TrimSpace(reference) - if strings.HasPrefix(t, "<") && strings.HasSuffix(t, ">") { - t = t[1 : len(t)-1] - } - r, ok := resolveAssetReference(assets, notePath, t) - if !ok || r.path != oldPath { - return assetReferenceResolution{}, false - } - return r, true - } - type edit struct { - start, end int - text string - } - var edits []edit - mask := wikiCodeMask(body) - for _, m := range assetWikilinkRe.FindAllStringSubmatchIndex(body, -1) { - if mask[m[0]] { - continue - } - embed := body[m[2]:m[3]] - content := body[m[4]:m[5]] - target, rest := content, "" - if p := strings.IndexByte(content, '|'); p >= 0 { - target, rest = content[:p], content[p:] - } - r, ok := resolveOld(target) - if !ok { - continue - } - next := embed + "[[" + assetRetarget(target, r, noteDir, newPath, bareStillUnique, true) + rest + "]]" - if next == body[m[0]:m[1]] { - continue // a bare name that still resolves reads exactly as before - } - edits = append(edits, edit{m[0], m[1], next}) - } - for _, m := range assetMdDestRe.FindAllStringSubmatchIndex(body, -1) { - if mask[m[0]] { - continue - } - href := body[m[2]:m[3]] - title := "" - if m[4] >= 0 { - title = body[m[4]:m[5]] - } - r, ok := resolveOld(href) - if !ok { - continue - } - next := "](" + assetRetarget(href, r, noteDir, newPath, bareStillUnique, false) + title + ")" - if next == body[m[0]:m[1]] { - continue - } - edits = append(edits, edit{m[0], m[1], next}) - } - if len(edits) == 0 { - return body, 0 - } - sort.Slice(edits, func(i, j int) bool { return edits[i].start < edits[j].start }) - var sb strings.Builder - last, changed := 0, 0 - for _, e := range edits { - if e.start < last { - continue // overlapped an earlier edit; keep the first - } - sb.WriteString(body[last:e.start]) - sb.WriteString(e.text) - last = e.end - changed++ - } - sb.WriteString(body[last:]) - return sb.String(), changed -} - -// rewriteAssetReferences rewrites every note that referenced the renamed or moved asset. -// Only notes that can hold a reference are read: the ones flagged -// HasAttachments (embeds and file links), plus any whose plain wikilinks name -// a file that resolves to the asset. -func (v *Vault) rewriteAssetReferences(notesBefore []NoteMeta, assetsBefore []AssetMeta, oldRel, newRel string) { - for _, n := range notesBefore { - if n.Folder == FolderTrash { - continue - } - candidate := n.HasAttachments - if !candidate { - for _, t := range n.Wikilinks { - if localAssetTargetKind(t) == "" { - continue - } - if r, ok := resolveAssetPathAmong(assetsBefore, n.Path, t); ok && r == oldRel { - candidate = true - break - } - } - } - if !candidate { - continue - } - content, err := v.ReadNote(n.Path) - if err != nil { - continue - } - body, changed := rewriteAssetReferencesInBody(content.Body, assetsBefore, n.Path, oldRel, newRel) - if changed > 0 { - _, _ = v.WriteNote(n.Path, body) - } - } -} diff --git a/apps/server/internal/vault/asset_ops_test.go b/apps/server/internal/vault/asset_ops_test.go deleted file mode 100644 index acd14870..00000000 --- a/apps/server/internal/vault/asset_ops_test.go +++ /dev/null @@ -1,464 +0,0 @@ -package vault - -import ( - "os" - "path/filepath" - "testing" -) - -// writeAsset drops a file at a vault-relative path, creating parent dirs. -func writeAsset(t *testing.T, root, rel, body string) { - t.Helper() - abs := filepath.Join(root, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(abs, []byte(body), 0o600); err != nil { - t.Fatal(err) - } -} - -func TestRenameAssetInPlace(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - meta, err := v.RenameAsset("assets/pic.png", "renamed.png") - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/renamed.png" { - t.Fatalf("renamed path = %q, want assets/renamed.png", meta.Path) - } - if meta.Kind != "image" { - t.Errorf("kind = %q, want image", meta.Kind) - } - if _, err := os.Stat(filepath.Join(root, "assets", "renamed.png")); err != nil { - t.Errorf("renamed file missing: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "assets", "pic.png")); !os.IsNotExist(err) { - t.Errorf("old file still present, err = %v", err) - } -} - -func TestRewriteAssetReferencesOnRename(t *testing.T) { - assets := []AssetMeta{ - {Path: "assets/old.png"}, {Path: "assets/other.png"}, {Path: "assets/old name.png"}, - {Path: "assets/dup.png"}, {Path: "docs/dup.png"}, - } - note := "inbox/Daily/2026-09-15.md" - cases := []struct { - in, want string - changed int - }{ - {"Shot: ![[assets/old.png]]\n", "Shot: ![[assets/new.png]]\n", 1}, - {"![[assets/old.png|300]] [[assets/old.png|the shot]] [[/assets/old.png#top]]", - "![[assets/new.png|300]] [[assets/new.png|the shot]] [[/assets/new.png#top]]", 3}, - {"![alt](assets/old.png \"Title\")\n[open](../../assets/old.png)\n[p](/assets/old.png#page=2)", - "![alt](assets/new.png \"Title\")\n[open](../../assets/new.png)\n[p](/assets/new.png#page=2)", 3}, - {"[![shot](assets/old.png)](assets/old.png)", "[![shot](assets/new.png)](assets/new.png)", 2}, - {"![[old.png]] ![](old.png)", "![[new.png]] ![](new.png)", 2}, - {"![[dup.png]]", "![[dup.png]]", 0}, - {"`![[assets/old.png]]`\n```\n![[assets/old.png]]\n```\n~~~\n![](assets/old.png)\n~~~\n", - "`![[assets/old.png]]`\n```\n![[assets/old.png]]\n```\n~~~\n![](assets/old.png)\n~~~\n", 0}, - {"![[assets/other.png]] [[Old Note]] [web](https://x/assets/old.png) ![](//cdn/assets/old.png)", - "![[assets/other.png]] [[Old Note]] [web](https://x/assets/old.png) ![](//cdn/assets/old.png)", 0}, - } - for _, c := range cases { - got, n := rewriteAssetReferencesInBody(c.in, assets, note, "assets/old.png", "assets/new.png") - if got != c.want || n != c.changed { - t.Errorf("rewrite(%q) = %q (%d), want %q (%d)", c.in, got, n, c.want, c.changed) - } - } - got, n := rewriteAssetReferencesInBody("![](assets/old%20name.png) ![]() ![[assets/old name.png]]", - assets, note, "assets/old name.png", "assets/new name.png") - if want := "![](assets/new%20name.png) ![]() ![[assets/new name.png]]"; got != want || n != 3 { - t.Errorf("spaced rename = %q (%d), want %q (3)", got, n, want) - } - if got, n := rewriteAssetReferencesInBody("![[assets/old.png]]", assets, note, "assets/old.png", "assets/old.png"); got != "![[assets/old.png]]" || n != 0 { - t.Errorf("same-name rename changed the body: %q (%d)", got, n) - } -} - -func TestRewriteAssetReferencesOnMove(t *testing.T) { - assets := []AssetMeta{ - {Path: "assets/old.png"}, {Path: "assets/other.png"}, {Path: "assets/old name.png"}, - {Path: "assets/dup.png"}, {Path: "docs/dup.png"}, - } - note := "inbox/Daily/2026-09-15.md" - cases := []struct { - in, want, newPath string - changed int - }{ - // Vault-root wikilinks and hrefs re-root; a spelled-out leading slash stays. - {"![[assets/old.png]] [[assets/old.png|the shot]] [p](/assets/old.png#page=2)", - "![[media/shots/old.png]] [[media/shots/old.png|the shot]] [p](/media/shots/old.png#page=2)", "media/shots/old.png", 3}, - // Note-relative stays relative to the note. - {"[open](../../assets/old.png \"Title\")", "[open](../../media/shots/old.png \"Title\")", "media/shots/old.png", 1}, - {"![](../../assets/old.png)", "![](old.png)", "inbox/Daily/old.png", 1}, - // A bare name stays bare while it still names one asset. - {"![[old.png]] ![](old.png)", "![[old.png]] ![](old.png)", "media/shots/old.png", 0}, - // It spells out the path once the bare name would be ambiguous. - {"![[old.png]] ![[assets/old.png]]", "![[docs/dup.png]] ![[docs/dup.png]]", "docs/dup.png", 2}, - {"![[old.png]]", "![[assets/dup.png]]", "assets/dup.png", 1}, - } - for _, c := range cases { - got, n := rewriteAssetReferencesInBody(c.in, assets, note, "assets/old.png", c.newPath) - if got != c.want || n != c.changed { - t.Errorf("move rewrite(%q → %q) = %q (%d), want %q (%d)", c.in, c.newPath, got, n, c.want, c.changed) - } - } - // A note at the vault root writes the plain path either way. - if got, _ := rewriteAssetReferencesInBody("![](assets/old.png)", assets, "Root.md", "assets/old.png", "media/old.png"); got != "![](media/old.png)" { - t.Errorf("root-note move = %q", got) - } - // An asset that sat next to its note: hrefs go relative, wikilinks stay bare or go vault-root, never `..`. - local := []AssetMeta{{Path: "inbox/pic.png"}, {Path: "inbox/sub/chart.png"}, {Path: "assets/other.png"}} - if got, _ := rewriteAssetReferencesInBody("![[pic.png]] ![alt](pic.png) [[inbox/pic.png|the pic]] ![[assets/other.png]]", local, "inbox/Pics.md", "inbox/pic.png", "media/shots/pic.png"); got != "![[pic.png]] ![alt](../media/shots/pic.png) [[media/shots/pic.png|the pic]] ![[assets/other.png]]" { - t.Errorf("same-folder move = %q", got) - } - if got, _ := rewriteAssetReferencesInBody("![[sub/chart.png]] ![](sub/chart.png)", local, "inbox/Pics.md", "inbox/sub/chart.png", "media/chart.png"); got != "![[media/chart.png]] ![](../media/chart.png)" { - t.Errorf("relative-folder move = %q", got) - } - if got, _ := rewriteAssetReferencesInBody("![[pic.png]]", local, "inbox/Pics.md", "inbox/pic.png", "assets/other.png"); got != "![[assets/other.png]]" { - t.Errorf("bare-collision move = %q", got) - } - got, n := rewriteAssetReferencesInBody("![](assets/old%20name.png) ![]() `![[assets/old name.png]]`", - assets, note, "assets/old name.png", "media/new name.png") - if want := "![](media/new%20name.png) ![]() `![[assets/old name.png]]`"; got != want || n != 2 { - t.Errorf("encoded move = %q (%d), want %q (2)", got, n, want) - } -} - -// End-to-end: a real MoveAsset re-targets the notes that referenced the asset -// in the author's style and leaves the rest alone. (#785) -func TestMoveAssetRewritesReferences(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/shot.png", "PNG") - if _, err := v.WriteNote("inbox/Rooted.md", "![[assets/shot.png|300]]\n[page](/assets/shot.png#page=2)\n"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("inbox/Daily/2026-09-15.md", "![shot](../../assets/shot.png \"Shot\")\n"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("inbox/Bare.md", "See ![[shot.png]] and [the file](shot.png).\n"); err != nil { - t.Fatal(err) - } - bareBefore, err := os.Stat(filepath.Join(root, "inbox", "Bare.md")) - if err != nil { - t.Fatal(err) - } - - meta, err := v.MoveAsset("assets/shot.png", "media/screenshots") - if err != nil { - t.Fatal(err) - } - if meta.Path != "media/screenshots/shot.png" { - t.Fatalf("moved path = %q, want media/screenshots/shot.png", meta.Path) - } - rooted, err := v.ReadNote("inbox/Rooted.md") - if err != nil { - t.Fatal(err) - } - if want := "![[media/screenshots/shot.png|300]]\n[page](/media/screenshots/shot.png#page=2)\n"; rooted.Body != want { - t.Fatalf("Rooted after move =\n%q\nwant\n%q", rooted.Body, want) - } - daily, err := v.ReadNote("inbox/Daily/2026-09-15.md") - if err != nil { - t.Fatal(err) - } - if want := "![shot](../../media/screenshots/shot.png \"Shot\")\n"; daily.Body != want { - t.Fatalf("Daily after move = %q, want %q", daily.Body, want) - } - bareAfter, err := os.Stat(filepath.Join(root, "inbox", "Bare.md")) - if err != nil { - t.Fatal(err) - } - if !bareAfter.ModTime().Equal(bareBefore.ModTime()) { - t.Errorf("bare-name note was rewritten though its links still resolve") - } -} - -// End-to-end: a real RenameAsset rewrites the notes that referenced the asset -// and leaves the rest alone. (#785) -func TestRenameAssetRewritesReferences(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/shot.png", "PNG") - writeAsset(t, root, "assets/other.png", "PNG") - body := "![[assets/shot.png]] ![[assets/shot.png|300]] ![alt](assets/shot.png \"Shot\") [[assets/shot.png|open]] ![[shot.png]]\n\n`![[assets/shot.png]]` ![[assets/other.png]]\n" - if _, err := v.WriteNote("inbox/Embeds.md", body); err != nil { - t.Fatal(err) - } - // A plain file link is neither an embed nor a note wikilink: HasAttachments carries it. - if _, err := v.WriteNote("inbox/Bare.md", "See [the file](shot.png).\n"); err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("inbox/Plain.md", "No pictures, just [[Embeds]].\n"); err != nil { - t.Fatal(err) - } - plainBefore, err := os.Stat(filepath.Join(root, "inbox", "Plain.md")) - if err != nil { - t.Fatal(err) - } - - meta, err := v.RenameAsset("assets/shot.png", "screenshot.png") - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/screenshot.png" { - t.Fatalf("renamed path = %q, want assets/screenshot.png", meta.Path) - } - - got, err := v.ReadNote("inbox/Embeds.md") - if err != nil { - t.Fatal(err) - } - want := "![[assets/screenshot.png]] ![[assets/screenshot.png|300]] ![alt](assets/screenshot.png \"Shot\") [[assets/screenshot.png|open]] ![[screenshot.png]]\n\n`![[assets/shot.png]]` ![[assets/other.png]]\n" - if got.Body != want { - t.Fatalf("Embeds after rename =\n%q\nwant\n%q", got.Body, want) - } - bare, err := v.ReadNote("inbox/Bare.md") - if err != nil { - t.Fatal(err) - } - if bare.Body != "See [the file](screenshot.png).\n" { - t.Fatalf("Bare after rename = %q", bare.Body) - } - plainAfter, err := os.Stat(filepath.Join(root, "inbox", "Plain.md")) - if err != nil { - t.Fatal(err) - } - if !plainAfter.ModTime().Equal(plainBefore.ModTime()) { - t.Errorf("a note without references was rewritten") - } -} - -func TestRenameAssetRejectsCollision(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/a.png", "A") - writeAsset(t, root, "assets/b.png", "B") - - if _, err := v.RenameAsset("assets/a.png", "b.png"); err == nil { - t.Fatal("expected collision error, got nil") - } - // Both originals must still be intact. - if _, err := os.Stat(filepath.Join(root, "assets", "a.png")); err != nil { - t.Errorf("source lost after failed rename: %v", err) - } -} - -func TestRenameAssetRejectsMarkdownAndDotDot(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - if _, err := v.RenameAsset("assets/pic.png", "note.md"); err == nil { - t.Error("expected error renaming asset to a .md name") - } - if _, err := v.RenameAsset("assets/pic.png", "sub/dir.png"); err == nil { - t.Error("expected error for a name containing a path separator") - } - if _, err := v.RenameAsset("inbox/Note.md", "x.png"); err == nil { - t.Error("expected error renaming a markdown note through RenameAsset") - } -} - -func TestMoveAssetIntoFolder(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - meta, err := v.MoveAsset("assets/pic.png", "media/screens") - if err != nil { - t.Fatal(err) - } - if meta.Path != "media/screens/pic.png" { - t.Fatalf("moved path = %q, want media/screens/pic.png", meta.Path) - } - if _, err := os.Stat(filepath.Join(root, "media", "screens", "pic.png")); err != nil { - t.Errorf("moved file missing: %v", err) - } - if _, err := os.Stat(filepath.Join(root, "assets", "pic.png")); !os.IsNotExist(err) { - t.Errorf("source still present after move, err = %v", err) - } -} - -func TestMoveAssetEmptyTargetGoesToAssetsDir(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - // A loose asset at the vault root, as a Vault Root-mode drop can leave. - writeAsset(t, root, "pic.png", "PNG") - - meta, err := v.MoveAsset("pic.png", "") - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/pic.png" { - t.Fatalf("moved path = %q, want assets/pic.png", meta.Path) - } -} - -func TestMoveAssetUniquifiesOnCollision(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "SRC") - writeAsset(t, root, "media/pic.png", "EXISTING") - - meta, err := v.MoveAsset("assets/pic.png", "media") - if err != nil { - t.Fatal(err) - } - if meta.Path != "media/pic 2.png" { - t.Fatalf("moved path = %q, want media/pic 2.png", meta.Path) - } - // The pre-existing file must be untouched. - body, err := os.ReadFile(filepath.Join(root, "media", "pic.png")) - if err != nil || string(body) != "EXISTING" { - t.Errorf("pre-existing file clobbered: body=%q err=%v", body, err) - } -} - -func TestMoveAssetSameDirIsNoop(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - meta, err := v.MoveAsset("assets/pic.png", "assets") - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/pic.png" { - t.Fatalf("no-op move path = %q, want assets/pic.png", meta.Path) - } -} - -func TestFolderColorsRoundTripAndValidation(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{ - FolderColors: map[string]FolderColorID{ - "inbox:Projects": "violet", - "inbox:Bad": "chartreuse", // not a preset — must be dropped - "": "blue", // empty key — must be dropped - }, - }); err != nil { - t.Fatal(err) - } - got, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.FolderColors["inbox:Projects"] != "violet" { - t.Fatalf("folderColors did not round-trip: %v", got.FolderColors) - } - if _, ok := got.FolderColors["inbox:Bad"]; ok { - t.Error("invalid color id was persisted") - } - if _, ok := got.FolderColors[""]; ok { - t.Error("empty-key color was persisted") - } -} - -func TestFolderColorsFollowFolderRename(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "Projects"), 0o700); err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{ - FolderColors: map[string]FolderColorID{"inbox:Projects": "teal"}, - }); err != nil { - t.Fatal(err) - } - if _, err := v.RenameFolder("inbox", "Projects", "Work"); err != nil { - t.Fatal(err) - } - got, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.FolderColors["inbox:Work"] != "teal" { - t.Errorf("color did not follow rename: %v", got.FolderColors) - } - if _, ok := got.FolderColors["inbox:Projects"]; ok { - t.Error("stale color key survived rename") - } -} - -func TestFolderColorsPrunedOnDeleteAndCopiedOnDuplicate(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "Projects"), 0o700); err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{ - FolderColors: map[string]FolderColorID{"inbox:Projects": "pink"}, - }); err != nil { - t.Fatal(err) - } - - rel, err := v.DuplicateFolder("inbox", "Projects") - if err != nil { - t.Fatal(err) - } - dup, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if dup.FolderColors["inbox:"+rel] != "pink" { - t.Errorf("duplicate did not inherit color (key inbox:%s): %v", rel, dup.FolderColors) - } - if dup.FolderColors["inbox:Projects"] != "pink" { - t.Errorf("source color lost after duplicate: %v", dup.FolderColors) - } - - if err := v.DeleteFolder("inbox", "Projects"); err != nil { - t.Fatal(err) - } - del, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if _, ok := del.FolderColors["inbox:Projects"]; ok { - t.Error("deleted folder's color was not pruned") - } -} diff --git a/apps/server/internal/vault/asset_trash.go b/apps/server/internal/vault/asset_trash.go deleted file mode 100644 index 84c400f2..00000000 --- a/apps/server/internal/vault/asset_trash.go +++ /dev/null @@ -1,256 +0,0 @@ -package vault - -import ( - "crypto/rand" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "time" -) - -// Asset deletion mirrors the desktop implementation (vault.ts) exactly: -// a delete moves the file into .zennotes/deleted-assets// next to -// a .zn-deleted.json holding the original location, so the Trash view can -// list and restore it even across restarts and across the two -// implementations. Both apps read each other's stores; the on-disk layout is -// a contract, not an implementation detail. -const ( - deletedAssetsDir = "deleted-assets" - deletedAssetMetaFile = ".zn-deleted.json" -) - -// Matches the desktop's token validation: 36 chars of hex and dashes. -var deletedAssetTokenRe = regexp.MustCompile(`^[0-9a-fA-F-]{36}$`) - -func newUndoToken() (string, error) { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - return "", err - } - b[6] = (b[6] & 0x0f) | 0x40 - b[8] = (b[8] & 0x3f) | 0x80 - return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil -} - -func cleanDeletedAssetToken(token string) (string, error) { - if !deletedAssetTokenRe.MatchString(token) { - return "", errors.New("deleted asset restore token is invalid") - } - return token, nil -} - -func cleanDeletedAssetPath(rel string) (string, error) { - normalized := strings.Trim(strings.TrimSpace(filepath.ToSlash(rel)), "/") - if normalized == "" { - return "", errors.New("deleted asset path is required") - } - for _, part := range strings.Split(normalized, "/") { - if part == internalVaultDir { - return "", errors.New("cannot restore internal ZenNotes files") - } - } - if strings.EqualFold(filepath.Ext(normalized), ".md") { - return "", errors.New("use note actions to restore markdown notes") - } - return normalized, nil -} - -func (v *Vault) deletedAssetsRoot() string { - return filepath.Join(v.root, internalVaultDir, deletedAssetsDir) -} - -// DuplicateAsset copies an asset next to itself as " copy", -// mirroring the desktop duplicateAsset. -func (v *Vault) DuplicateAsset(rel string) (AssetMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - srcAbs, err := v.assertAssetFile(rel) - if err != nil { - return AssetMeta{}, err - } - ext := filepath.Ext(srcAbs) - stem := strings.TrimSuffix(filepath.Base(srcAbs), ext) - destAbs := uniquePath(filepath.Dir(srcAbs), stem+" copy", ext) - if err := copyFile(srcAbs, destAbs, v.fileMode); err != nil { - return AssetMeta{}, err - } - return v.assetMetaForAbs(destAbs) -} - -// DeleteAsset moves an asset into the deleted-assets store and returns the -// restore handle, mirroring the desktop deleteAsset. -func (v *Vault) DeleteAsset(rel string) (DeletedAsset, error) { - v.mu.Lock() - defer v.mu.Unlock() - srcAbs, err := v.assertAssetFile(rel) - if err != nil { - return DeletedAsset{}, err - } - srcRel, err := filepath.Rel(v.root, srcAbs) - if err != nil { - return DeletedAsset{}, err - } - undoToken, err := newUndoToken() - if err != nil { - return DeletedAsset{}, err - } - trashDir := filepath.Join(v.deletedAssetsRoot(), undoToken) - if err := os.MkdirAll(trashDir, v.dirMode); err != nil { - return DeletedAsset{}, err - } - name := filepath.Base(srcAbs) - deletedAt := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") - deleted := DeletedAsset{ - Path: filepath.ToSlash(srcRel), - Name: name, - UndoToken: undoToken, - DeletedAt: deletedAt, - } - meta, err := json.MarshalIndent(map[string]string{ - "path": deleted.Path, - "name": deleted.Name, - "deletedAt": deleted.DeletedAt, - }, "", " ") - if err != nil { - return DeletedAsset{}, err - } - // Metadata first, file move last: a failure anywhere leaves the asset - // still in the vault. The old order (rename, then metadata) could hit a - // write error (disk full, permissions) after the move and strand the - // asset in a token dir the Trash view skips, gone from the vault with no - // in-app way back. - if err := os.WriteFile(filepath.Join(trashDir, deletedAssetMetaFile), meta, v.fileMode); err != nil { - _ = os.RemoveAll(trashDir) - return DeletedAsset{}, err - } - if err := os.Rename(srcAbs, filepath.Join(trashDir, name)); err != nil { - _ = os.RemoveAll(trashDir) - return DeletedAsset{}, err - } - return deleted, nil -} - -// ListDeletedAssets enumerates restorable entries in the deleted-assets -// store, newest first. Entries without metadata are skipped, exactly like the -// desktop (pre-2.11 deletes have none). -func (v *Vault) ListDeletedAssets() ([]DeletedAsset, error) { - v.mu.RLock() - defer v.mu.RUnlock() - entries, err := os.ReadDir(v.deletedAssetsRoot()) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return []DeletedAsset{}, nil - } - return nil, err - } - out := []DeletedAsset{} - for _, entry := range entries { - undoToken := entry.Name() - raw, err := os.ReadFile(filepath.Join(v.deletedAssetsRoot(), undoToken, deletedAssetMetaFile)) - if err != nil { - continue - } - var meta struct { - Path string `json:"path"` - Name string `json:"name"` - DeletedAt string `json:"deletedAt"` - } - if err := json.Unmarshal(raw, &meta); err != nil || meta.Path == "" || meta.Name == "" { - continue - } - // The asset file itself must still be present to be restorable. - if _, err := os.Stat(filepath.Join(v.deletedAssetsRoot(), undoToken, meta.Name)); err != nil { - continue - } - out = append(out, DeletedAsset{ - Path: meta.Path, - Name: meta.Name, - UndoToken: undoToken, - DeletedAt: meta.DeletedAt, - }) - } - sort.SliceStable(out, func(i, j int) bool { - return out[i].DeletedAt > out[j].DeletedAt - }) - return out, nil -} - -// RestoreDeletedAsset moves an asset back to its original folder, deduping -// the filename if something new took its place, mirroring the desktop. -// Only the token comes from the caller; the stored .zn-deleted.json decides -// what gets restored and where. Trusting a client-supplied name here once -// let a request naming the metadata file itself "restore" that file and -// then destroy the real asset bytes with the trash dir cleanup. -func (v *Vault) RestoreDeletedAsset(deleted DeletedAsset) (AssetMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - undoToken, err := cleanDeletedAssetToken(deleted.UndoToken) - if err != nil { - return AssetMeta{}, err - } - trashDir := filepath.Join(v.deletedAssetsRoot(), undoToken) - raw, err := os.ReadFile(filepath.Join(trashDir, deletedAssetMetaFile)) - if err != nil { - return AssetMeta{}, errors.New("deleted asset entry not found") - } - var stored struct { - Path string `json:"path"` - Name string `json:"name"` - } - if err := json.Unmarshal(raw, &stored); err != nil { - return AssetMeta{}, errors.New("deleted asset entry is unreadable") - } - targetRel, err := cleanDeletedAssetPath(stored.Path) - if err != nil { - return AssetMeta{}, err - } - name, err := cleanAssetFilename(stored.Name) - if err != nil { - return AssetMeta{}, err - } - if name == deletedAssetMetaFile { - return AssetMeta{}, errors.New("deleted asset entry is unreadable") - } - srcAbs := filepath.Join(trashDir, name) - targetAbs, err := SafeJoin(v.root, targetRel) - if err != nil { - return AssetMeta{}, err - } - targetDir := filepath.Dir(targetAbs) - if err := os.MkdirAll(targetDir, v.dirMode); err != nil { - return AssetMeta{}, err - } - base := filepath.Base(targetAbs) - ext := filepath.Ext(base) - finalAbs := uniquePath(targetDir, strings.TrimSuffix(base, ext), ext) - if err := os.Rename(srcAbs, finalAbs); err != nil { - return AssetMeta{}, err - } - if err := os.RemoveAll(trashDir); err != nil { - return AssetMeta{}, err - } - return v.assetMetaForAbs(finalAbs) -} - -// PurgeDeletedAsset permanently deletes one entry from the store. -func (v *Vault) PurgeDeletedAsset(undoToken string) error { - v.mu.Lock() - defer v.mu.Unlock() - token, err := cleanDeletedAssetToken(undoToken) - if err != nil { - return err - } - return os.RemoveAll(filepath.Join(v.deletedAssetsRoot(), token)) -} - -// EmptyDeletedAssets permanently deletes every entry in the store. -func (v *Vault) EmptyDeletedAssets() error { - v.mu.Lock() - defer v.mu.Unlock() - return os.RemoveAll(v.deletedAssetsRoot()) -} diff --git a/apps/server/internal/vault/asset_trash_test.go b/apps/server/internal/vault/asset_trash_test.go deleted file mode 100644 index 5faf9cd9..00000000 --- a/apps/server/internal/vault/asset_trash_test.go +++ /dev/null @@ -1,226 +0,0 @@ -package vault - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestDeleteAssetMovesIntoStoreAndListsBack(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - deleted, err := v.DeleteAsset("assets/pic.png") - if err != nil { - t.Fatal(err) - } - if deleted.Path != "assets/pic.png" || deleted.Name != "pic.png" { - t.Fatalf("deleted = %+v, want original path+name", deleted) - } - if !deletedAssetTokenRe.MatchString(deleted.UndoToken) { - t.Fatalf("undo token %q does not match the desktop token shape", deleted.UndoToken) - } - if _, err := os.Stat(filepath.Join(root, "assets", "pic.png")); !os.IsNotExist(err) { - t.Fatal("original file still present after delete") - } - stored := filepath.Join(root, internalVaultDir, deletedAssetsDir, deleted.UndoToken, "pic.png") - if _, err := os.Stat(stored); err != nil { - t.Fatalf("stored file missing: %v", err) - } - - listed, err := v.ListDeletedAssets() - if err != nil { - t.Fatal(err) - } - if len(listed) != 1 || listed[0].UndoToken != deleted.UndoToken || listed[0].Path != "assets/pic.png" { - t.Fatalf("listed = %+v, want the deleted entry", listed) - } -} - -func TestRestoreDeletedAssetReturnsToOriginalFolderAndDedupes(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "docs/report.pdf", "PDF-1") - deleted, err := v.DeleteAsset("docs/report.pdf") - if err != nil { - t.Fatal(err) - } - // Something new takes the original name before the restore. - writeAsset(t, root, "docs/report.pdf", "PDF-2") - - meta, err := v.RestoreDeletedAsset(deleted) - if err != nil { - t.Fatal(err) - } - if meta.Path != "docs/report 2.pdf" { - t.Fatalf("restored path = %q, want the deduped docs/report 2.pdf", meta.Path) - } - body, err := os.ReadFile(filepath.Join(root, "docs", "report 2.pdf")) - if err != nil { - t.Fatal(err) - } - if string(body) != "PDF-1" { - t.Fatalf("restored body = %q, want the deleted bytes", body) - } - // The store entry is consumed by the restore. - if listed, _ := v.ListDeletedAssets(); len(listed) != 0 { - t.Fatalf("store still lists %d entries after restore", len(listed)) - } -} - -func TestRestoreDeletedAssetRejectsBadInput(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - base := DeletedAsset{Path: "assets/x.png", Name: "x.png", UndoToken: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"} - - bad := base - bad.UndoToken = "../../../etc" - if _, err := v.RestoreDeletedAsset(bad); err == nil { - t.Fatal("path-traversal token accepted") - } - bad = base - bad.Path = ".zennotes/vault.json" - if _, err := v.RestoreDeletedAsset(bad); err == nil { - t.Fatal("internal path accepted") - } - bad = base - bad.Path = "notes/Note.md" - if _, err := v.RestoreDeletedAsset(bad); err == nil { - t.Fatal("markdown path accepted") - } - bad = base - bad.Name = "../vault.json" - if _, err := v.RestoreDeletedAsset(bad); err == nil { - t.Fatal("path-escaping name accepted") - } -} - -func TestRestoreDeletedAssetFollowsStoredMetadataNotTheRequest(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - deleted, err := v.DeleteAsset("assets/pic.png") - if err != nil { - t.Fatal(err) - } - - // A hostile request keeps a valid token but names the metadata file as the - // thing to restore. Trusting it would rename .zn-deleted.json into the - // vault and then purge the real asset bytes with the trash dir cleanup. - hostile := deleted - hostile.Name = deletedAssetMetaFile - hostile.Path = "assets/x.json" - meta, err := v.RestoreDeletedAsset(hostile) - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/pic.png" { - t.Fatalf("restored path = %q, want the stored assets/pic.png", meta.Path) - } - body, err := os.ReadFile(filepath.Join(root, "assets", "pic.png")) - if err != nil { - t.Fatal(err) - } - if string(body) != "PNG" { - t.Fatalf("restored body = %q, want the original asset bytes", body) - } -} - -func TestPurgeAndEmptyDeletedAssets(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "a.png", "A") - writeAsset(t, root, "b.png", "B") - delA, err := v.DeleteAsset("a.png") - if err != nil { - t.Fatal(err) - } - if _, err := v.DeleteAsset("b.png"); err != nil { - t.Fatal(err) - } - - if err := v.PurgeDeletedAsset("not-a-token"); err == nil { - t.Fatal("invalid token accepted by purge") - } - if err := v.PurgeDeletedAsset(delA.UndoToken); err != nil { - t.Fatal(err) - } - listed, err := v.ListDeletedAssets() - if err != nil { - t.Fatal(err) - } - if len(listed) != 1 || listed[0].Name != "b.png" { - t.Fatalf("after purge listed = %+v, want only b.png", listed) - } - - if err := v.EmptyDeletedAssets(); err != nil { - t.Fatal(err) - } - if listed, _ := v.ListDeletedAssets(); len(listed) != 0 { - t.Fatalf("after empty listed = %+v, want none", listed) - } -} - -func TestDeleteAssetRefusesNotesAndInternalFiles(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "inbox/Note.md", "note") - if _, err := v.DeleteAsset("inbox/Note.md"); err == nil { - t.Fatal("markdown note accepted by asset delete") - } - if _, err := v.DeleteAsset(".zennotes/vault.json"); err == nil { - t.Fatal("internal file accepted by asset delete") - } -} - -func TestDuplicateAssetCopiesNextToSource(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeAsset(t, root, "assets/pic.png", "PNG") - - meta, err := v.DuplicateAsset("assets/pic.png") - if err != nil { - t.Fatal(err) - } - if meta.Path != "assets/pic copy.png" { - t.Fatalf("duplicate path = %q, want assets/pic copy.png", meta.Path) - } - body, err := os.ReadFile(filepath.Join(root, "assets", "pic copy.png")) - if err != nil { - t.Fatal(err) - } - if string(body) != "PNG" { - t.Fatalf("duplicate body = %q", body) - } - // A second duplicate dedupes with the shared numbering scheme. - again, err := v.DuplicateAsset("assets/pic.png") - if err != nil { - t.Fatal(err) - } - if !strings.HasSuffix(again.Path, "pic copy 2.png") { - t.Fatalf("second duplicate = %q, want pic copy 2.png", again.Path) - } -} diff --git a/apps/server/internal/vault/atomicwrite.go b/apps/server/internal/vault/atomicwrite.go deleted file mode 100644 index d1ee5a5e..00000000 --- a/apps/server/internal/vault/atomicwrite.go +++ /dev/null @@ -1,171 +0,0 @@ -package vault - -import ( - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "regexp" - "runtime" - "syscall" - "time" -) - -// The scratch file writeFileAtomic renames from: `...tmp`. -// The shape is shared with the desktop app's writeFileAtomic, and both watchers -// filter on it, so the two must stay recognizable to each other. The trailing -// number is an epoch stamp (millis on the desktop, nanos here), and requiring -// its length is what keeps a file the user actually named `notes.2024.01.tmp` -// out of the filter: events we drop here are events no client ever hears about. -var atomicWriteTempPattern = regexp.MustCompile(`\.\d+\.\d{13,}\.tmp$`) - -// IsAtomicWriteTempPath reports whether p is one of those scratch files. The -// watcher drops them: a temp file appearing and vanishing is not a vault -// change, and a client that heard about it would rebuild its asset list on -// every keystroke-driven note save. -func IsAtomicWriteTempPath(p string) bool { - return atomicWriteTempPattern.MatchString(filepath.Base(p)) -} - -// writeFileAtomic writes data to abs by way of a temp file in the same -// directory, fsynced, then renamed over the target. The rename is atomic, so no -// reader can ever observe a truncated or half-written file. That is what keeps -// a save from erasing the note it is saving: the file watcher echoes each save -// back to every client, and with a truncate-then-write the echo of one save -// could read the file inside the next save's empty window and hand clients an -// empty note (#585). -// -// A rename replaces the DIRECTORY ENTRY, which would silently take away two -// properties the plain os.WriteFile this replaced had for free: -// -// - A symlinked note gets written THROUGH, not over. Pointed straight at a -// link, the rename would leave a regular file where the link was and detach -// it from its target for good. SafeJoin has already proved the target -// resolves inside the vault. -// - An existing file keeps its own permissions. os.WriteFile only applies its -// mode when it creates the file, so a note the operator chmod'ed stays as -// they left it; fileMode applies only to files this call creates. -func writeFileAtomic(abs string, data []byte, fileMode, dirMode fs.FileMode) error { - target, err := resolveLinkTarget(abs) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(target), dirMode); err != nil { - return err - } - - mode := fileMode - replacing := false - if info, statErr := os.Stat(target); statErr == nil { - mode = info.Mode().Perm() - replacing = true - } else if !errors.Is(statErr, os.ErrNotExist) { - return statErr - } - - temp := fmt.Sprintf("%s.%d.%d.tmp", target, os.Getpid(), time.Now().UnixNano()) - f, err := os.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) - if err != nil { - return err - } - if err := writeAndSync(f, data); err != nil { - _ = f.Close() - _ = os.Remove(temp) - return err - } - if err := f.Close(); err != nil { - _ = os.Remove(temp) - return err - } - // O_CREATE runs the mode through the process umask, so reproducing the mode - // of a file we are replacing takes an explicit chmod (0664 under umask 022, - // say). A file this call creates is deliberately left umasked, which is what - // os.WriteFile did with fileMode. On Windows chmod touches nothing but the - // read-only bit, which is the most it can mean there. - if replacing { - if err := os.Chmod(temp, mode); err != nil { - _ = os.Remove(temp) - return err - } - } - if err := renameWithRetry(temp, target, os.Rename, time.Sleep); err != nil { - _ = os.Remove(temp) - return err - } - return nil -} - -const atomicRenameAttempts = 20 -const windowsSharingViolation syscall.Errno = 32 - -func transientRenameError(err error) bool { - if errors.Is(err, fs.ErrPermission) { - return true - } - var errno syscall.Errno - return runtime.GOOS == "windows" && errors.As(err, &errno) && errno == windowsSharingViolation -} - -// Windows refuses a replace while any reader has the destination open without -// delete sharing. Watchers, indexers, and antivirus scanners all create that -// short-lived condition, so wait for the handle instead of failing the save. -func renameWithRetry( - from, to string, - rename func(string, string) error, - sleep func(time.Duration), -) error { - delay := time.Millisecond - for attempt := 1; ; attempt++ { - err := rename(from, to) - if err == nil { - return nil - } - if attempt >= atomicRenameAttempts || !transientRenameError(err) { - return err - } - sleep(delay) - delay = min(delay*2, 25*time.Millisecond) - } -} - -func writeAndSync(f *os.File, data []byte) error { - if _, err := f.Write(data); err != nil { - return err - } - // The bytes have to reach the disk before the rename publishes them, or a - // crash can leave the entry pointing at a file with nothing in it. - return f.Sync() -} - -// resolveLinkTarget follows a symlink at abs to the file it points at, so the -// atomic write lands on the target rather than replacing the link. A dangling -// link resolves to the path it names, which is where a plain write would have -// created the file. -func resolveLinkTarget(abs string) (string, error) { - info, err := os.Lstat(abs) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return abs, nil - } - return "", err - } - if info.Mode()&os.ModeSymlink == 0 { - return abs, nil - } - resolved, err := filepath.EvalSymlinks(abs) - if err == nil { - return resolved, nil - } - if !errors.Is(err, os.ErrNotExist) { - return "", err - } - dest, err := os.Readlink(abs) - if err != nil { - return "", err - } - if filepath.IsAbs(dest) { - return dest, nil - } - return filepath.Join(filepath.Dir(abs), dest), nil -} diff --git a/apps/server/internal/vault/atomicwrite_test.go b/apps/server/internal/vault/atomicwrite_test.go deleted file mode 100644 index 570afd1f..00000000 --- a/apps/server/internal/vault/atomicwrite_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package vault - -import ( - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" - "time" -) - -// The #585 property, and the whole reason WriteNote is atomic: the watcher -// echoes every save to every client, and a client that reads the file inside a -// truncate-then-write window gets an empty note and shows it as the truth. No -// reader may ever observe anything but a complete body. -func TestWriteNoteNeverExposesAPartialFile(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - const rel = "inbox/race.md" - // Big enough that the write is not a single instantaneous syscall. - bodyA := strings.Repeat("A", 96*1024) - bodyB := strings.Repeat("B", 96*1024) - if _, err := v.WriteNote(rel, bodyA); err != nil { - t.Fatal(err) - } - abs := filepath.Join(v.Root(), "inbox", "race.md") - - stop := make(chan struct{}) - bad := make(chan string, 1) - var readers sync.WaitGroup - readers.Add(1) - go func() { - defer readers.Done() - for { - select { - case <-stop: - return - default: - } - data, err := os.ReadFile(abs) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - select { - case bad <- "the note vanished mid-save": - default: - } - return - } - continue - } - if body := string(data); body != bodyA && body != bodyB { - select { - case bad <- fmt.Sprintf("a reader saw %d bytes, neither the old body nor the new one", len(body)): - default: - } - return - } - } - }() - - for i := range 200 { - body := bodyA - if i%2 == 1 { - body = bodyB - } - if _, err := v.WriteNote(rel, body); err != nil { - t.Fatal(err) - } - } - close(stop) - readers.Wait() - - select { - case msg := <-bad: - t.Fatal(msg) - default: - } -} - -func TestRenameWithRetryWaitsOutTransientPermissionErrors(t *testing.T) { - calls := 0 - var delays []time.Duration - err := renameWithRetry( - "note.tmp", - "note.md", - func(_, _ string) error { - calls++ - if calls < 3 { - return fs.ErrPermission - } - return nil - }, - func(delay time.Duration) { delays = append(delays, delay) }, - ) - - if err != nil { - t.Fatal(err) - } - if calls != 3 { - t.Fatalf("rename calls = %d, want 3", calls) - } - if len(delays) != 2 || delays[0] <= 0 || delays[1] <= delays[0] { - t.Fatalf("retry delays = %v, want two increasing delays", delays) - } -} - -// A rename replaces the directory entry, so an atomic write aimed straight at a -// symlinked note would leave a regular file where the link was and detach it -// from its target for good. -func TestWriteNoteFollowsSymlinkInsteadOfReplacingIt(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - realAbs := filepath.Join(v.Root(), "inbox", "real.md") - if err := os.WriteFile(realAbs, []byte("original"), 0o600); err != nil { - t.Fatal(err) - } - link := filepath.Join(v.Root(), "inbox", "link.md") - // Target inside the vault, which is what SafeJoin permits. - if err := os.Symlink(realAbs, link); err != nil { - t.Fatal(err) - } - - if _, err := v.WriteNote("inbox/link.md", "written through the link"); err != nil { - t.Fatal(err) - } - - info, err := os.Lstat(link) - if err != nil { - t.Fatal(err) - } - if info.Mode()&os.ModeSymlink == 0 { - t.Fatal("the symlink was replaced by a regular file") - } - got, err := os.ReadFile(realAbs) - if err != nil { - t.Fatal(err) - } - if string(got) != "written through the link" { - t.Fatalf("link target holds %q, want the written body", got) - } -} - -// os.WriteFile only applied its mode when it created the file, so replacing it -// with temp-plus-rename must not quietly re-permission notes the operator (or -// another tool) left with a mode of their own. -func TestWriteNotePreservesModeOfAnExistingNote(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("file modes are not meaningful on windows") - } - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - shared := filepath.Join(v.Root(), "inbox", "shared.md") - if err := os.WriteFile(shared, []byte("x"), 0o640); err != nil { - t.Fatal(err) - } - if err := os.Chmod(shared, 0o640); err != nil { // defeat the process umask - t.Fatal(err) - } - - if _, err := v.WriteNote("inbox/shared.md", "updated"); err != nil { - t.Fatal(err) - } - info, err := os.Stat(shared) - if err != nil { - t.Fatal(err) - } - if perm := info.Mode().Perm(); perm != 0o640 { - t.Fatalf("mode after save = %v, want 0640", perm) - } - - // A note this call creates still gets the vault's configured mode. - if _, err := v.WriteNote("inbox/fresh.md", "new"); err != nil { - t.Fatal(err) - } - fresh, err := os.Stat(filepath.Join(v.Root(), "inbox", "fresh.md")) - if err != nil { - t.Fatal(err) - } - if perm := fresh.Mode().Perm(); perm != 0o600 { - t.Fatalf("new note mode = %v, want the vault's 0600", perm) - } -} - -func TestWriteNoteLeavesNoScratchFiles(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - for range 3 { - if _, err := v.WriteNote("inbox/note.md", "body"); err != nil { - t.Fatal(err) - } - } - entries, err := os.ReadDir(filepath.Join(v.Root(), "inbox")) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if strings.HasSuffix(entry.Name(), ".tmp") { - t.Fatalf("a scratch file survived the save: %s", entry.Name()) - } - } -} - -func TestIsAtomicWriteTempPath(t *testing.T) { - cases := []struct { - path string - want bool - }{ - {"inbox/note.md.4123.1786714355519.tmp", true}, // desktop, millis - {"inbox/note.md.4123.1786714355519123456.tmp", true}, // server, nanos - {"inbox/note.md", false}, - {"inbox/note.tmp", false}, - // A file the user named themselves keeps its live updates: the trailing - // group is too short to be an epoch stamp. - {"inbox/report.2024.01.tmp", false}, - } - for _, c := range cases { - if got := IsAtomicWriteTempPath(c.path); got != c.want { - t.Errorf("IsAtomicWriteTempPath(%q) = %v, want %v", c.path, got, c.want) - } - } -} diff --git a/apps/server/internal/vault/demo-tour.json b/apps/server/internal/vault/demo-tour.json deleted file mode 100644 index 29ee1cb8..00000000 --- a/apps/server/internal/vault/demo-tour.json +++ /dev/null @@ -1 +0,0 @@ -{"notes":[{"path":"inbox/demo/00 — Start Here.md","body":"# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local files\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n"},{"path":"inbox/demo/01 — Markdown Basics.md","body":"# Markdown basics\n\nZenNotes starts with ordinary markdown. The app adds keyboard-first workflows around it, but the source stays portable and readable everywhere.\n\n## Headings\n\n```\n# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n```\n\nHeadings matter for more than styling:\n\n- they show up in the **outline**\n- they can be folded with `zc` and unfolded with `zo`\n- long notes can be searched by heading with `Space p`\n\n## Emphasis\n\n*Italic* with single asterisks, **bold** with double, ***bold italic*** with triple, `inline code` with backticks, ~~strikethrough~~ with tildes, and ==highlight== with double equals.\n\n## Paragraphs and line breaks\n\nA blank line starts a new paragraph.\nA single newline usually stays in the same paragraph.\n\nLeave two trailing spaces when you really want a hard line break. \nLike this.\n\n## Lists\n\nUnordered:\n\n- Apples\n- Bananas\n - Cavendish\n - Plantain\n- Cherries\n\nOrdered:\n\n1. Draft the note\n2. Refine the structure\n3. Ship the change\n\n## Links\n\n- External: [ZenNotes](https://lumarylabs.com)\n- Autolink: \n- Wikilink: [[07 — Wiki Links and Tags]]\n- Custom label: [[11 — Workspace, Search, and Views|workspace guide]]\n\n## Blockquotes and dividers\n\n> Markdown still does a lot with very little.\n>\n> ZenNotes just makes it faster to navigate and work with.\n\n---\n\n## Frontmatter\n\nYAML frontmatter works fine at the top of a note:\n\n```yaml\n---\ntitle: My Note\ndate: 2026-04-16\ntags: [project, research]\npriority: high\n---\n```\n\nZenNotes does not require frontmatter, but features like daily notes, tags, and task defaults can make use of it.\n\n## Slash commands\n\nZenNotes also helps you write these structures faster:\n\n- type `/` at the start of a line or after whitespace\n- choose items like headings, bullets, numbered lists, tasks, callouts, code blocks, tables, math blocks, links, images, and dividers\n- keep typing after `/` to filter the insert menu\n\nThat means markdown stays plain, but you do not have to remember every snippet from scratch.\n\n## What to try in this note\n\n- Put the cursor on a heading and fold it.\n- Switch the note between **Edit**, **Split**, and **Preview**.\n- Open the outline with `Space p`.\n- Search for this note with `Space f`.\n\n## What's next\n\nJump to [[02 — Code Blocks]] for syntax highlighting, [[06 — Callouts and Footnotes]] for richer block styles, or back to [[00 — Start Here]].\n\n#demo #markdown\n"},{"path":"inbox/demo/02 — Code Blocks.md","body":"# Code blocks\n\nZenNotes treats code fences as plain markdown on disk and renders them with syntax highlighting in preview and split view.\n\n## A fast way to insert them\n\nType `/` and choose **Code block** if you do not want to type the fence manually.\n\n## TypeScript\n\n```ts\nexport interface User {\n id: string\n name: string\n roles: string[]\n}\n\nexport async function fetchUser(id: string): Promise {\n const response = await fetch(`/api/users/${id}`)\n if (!response.ok) return null\n return (await response.json()) as User\n}\n```\n\n## Python\n\n```python\nfrom dataclasses import dataclass\n\n@dataclass\nclass Point:\n x: float\n y: float\n\n def distance_to(self, other: \"Point\") -> float:\n return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5\n```\n\n## Bash\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nfor note in inbox/*.md; do\n words=$(wc -w < \"$note\")\n printf \"%6d %s\\n\" \"$words\" \"$(basename \"$note\")\"\ndone\n```\n\n## Rust\n\n```rust\nuse std::collections::HashMap;\n\nfn word_count(text: &str) -> HashMap {\n let mut counts = HashMap::new();\n for word in text.split_whitespace() {\n *counts.entry(word.to_lowercase()).or_insert(0) += 1;\n }\n counts\n}\n```\n\n## JSON\n\n```json\n{\n \"name\": \"ZenNotes\",\n \"productName\": \"ZenNotes\",\n \"version\": \"0.1.0\",\n \"scripts\": {\n \"dev\": \"electron-vite dev\",\n \"build\": \"electron-vite build\"\n }\n}\n```\n\n## Diff\n\n```diff\n- Space /\n+ Space s t\n```\n\n## Plain text\n\n```\nNo language tag, no syntax highlighting.\nUseful for raw config examples or ASCII notes.\n```\n\n## Inline code\n\nUse `inline code` when the snippet belongs inside a sentence.\n\n## Workflow notes\n\n- **Edit** mode is best for writing or refactoring the raw fence.\n- **Split** mode is ideal when you want source on one side and highlighted output on the other.\n- Fenced blocks are ignored by the task scanner, so `- [ ]` inside code stays an example, not a live task.\n- Vault text search can still find matching text inside code fences because they are part of the note body.\n\n## What's next\n\nSee [[05 — Mermaid Diagrams]] for Mermaid fences, [[05b — Math Diagrams]] for TikZ, JSXGraph, and function-plot, or [[10 — Ideas and Tasks]] for how snippets mix with prose and planning in a real note.\n\n#demo #code\n"},{"path":"inbox/demo/03 — Tables and Task Lists.md","body":"# Tables and task lists\n\n## Tables\n\nPlain GFM tables. Alignment is controlled with colons in the divider row.\n\n| Feature | Support | Notes |\n| ---------- | :--------: | --------------------------------------------------------- |\n| Headings | ✅ | Fold from the editor gutter and jump via the outline. |\n| Wiki links | ✅ | `[[Title]]` resolves by note name. |\n| Tags | ✅ | Written inline as `#like-this`. |\n| Math | ✅ | KaTeX, inline and display. |\n| Mermaid | ✅ | Rendered inside preview and split view. |\n| Search | ✅ | Notes by title/path, vault text by fuzzy content search. |\n| Sync | File-based | Use any sync tool that watches folders. |\n\nRight-aligned numbers:\n\n| Quarter | Revenue | Delta |\n| ------: | -------: | -----: |\n| Q1 | $124,300 | +4.2% |\n| Q2 | $131,980 | +6.2% |\n| Q3 | $129,010 | −2.3% |\n| Q4 | $152,407 | +18.1% |\n\n## Task lists\n\nEvery checkbox survives on disk as normal markdown like `- [ ]` and `- [x]`.\n\n## What ZenNotes task parsing supports\n\n### Core checkboxes\n\n- [ ] Open task\n- [x] Completed task\n- [X] Uppercase `X` also counts as completed\n\n### Different list styles still count\n\n- [ ] Bulleted task using `-`\n+ [ ] Bulleted task using `+`\n* [ ] Bulleted task using `*`\n1. [ ] Ordered task using `1.`\n2) [ ] Ordered task using `2)`\n> - [ ] Blockquoted task lines are parsed too\n\n### Nested tasks\n\n- [ ] Weekly review\n - [ ] Clear inbox to zero\n - [ ] Triage [[10 — Ideas and Tasks]]\n - [x] Back up vault\n - [ ] Plan next week\n - [ ] Monday — design review\n - [ ] Tuesday — code-freeze prep\n - [x] Saturday — offline\n\n### Metadata tokens on the task line\n\n- [ ] Ship the onboarding checklist due:2026-04-18 !high #onboarding #docs\n- [ ] Refresh demo screenshots due:2026-04-22 !med #demo #assets\n- [ ] Clean up seed notes !low #maintenance\n- [ ] Wait for design sign-off @waiting #design\n- [ ] Review vault search UX due:2026-04-30 !high #search #ux\n\nThe parser understands these tokens:\n\n| Token | Meaning | Example |\n| ----- | ------- | ------- |\n| `due:YYYY-MM-DD` | ISO due date used for grouping | `due:2026-04-22` |\n| `!high` / `!med` / `!low` | Priority marker | `!high` |\n| `@waiting` | Moves the task into the Waiting group | `@waiting` |\n| `#tag` | Inline task tag, searchable in the Tasks view | `#design` |\n\n### What the Tasks view does with them\n\n- Tasks with no due date land in **Today**\n- Tasks due today or already overdue also land in **Today**\n- Tasks due in the future land in **Upcoming**\n- Tasks with `@waiting` land in **Waiting**\n- Checked tasks land in **Done**\n- Overdue tasks contribute to the overdue count in the **Today** section\n\n### Filtering and navigation\n\nPress the sidebar **Tasks** row to scan every live note across **Inbox**, **Quick Notes**, and **Archive**. From there you can:\n\n- filter by task content\n- filter by note title\n- filter by inline `#tags`\n- filter by priority markers like `!high`\n- press `Enter` or `o` to open the source note\n- press `Space` or `x` to toggle the selected task without leaving the list\n\n### Ignored on purpose\n\nTasks inside fenced code blocks are not parsed, so you can document task syntax safely:\n\n```md\n- [ ] This looks like a task\n- [x] But code fences are ignored by the vault-wide task scanner\n- [ ] That makes examples and snippets safe\n```\n\n### Note-level defaults\n\nYou can also set due date and priority defaults in frontmatter, then override them inline per task:\n\n```yaml\n---\ndue: 2026-05-01\npriority: high\n---\n```\n\nWith defaults like that, a plain line such as `- [ ] Draft roadmap` inherits the due date and priority even without repeating the tokens.\n\n### Rendering checklist\n\nEvery item below is wired up:\n\n- [x] Paragraphs\n- [x] Emphasis: _italic_, **bold**, ~~strike~~\n- [x] Ordered and unordered lists\n- [x] Tables\n- [x] Task lists\n- [x] Blockquotes\n- [x] Footnotes (see [[06 — Callouts and Footnotes]])\n- [x] Math blocks (see [[04 — Math with KaTeX]])\n- [x] Mermaid (see [[05 — Mermaid Diagrams]])\n- [x] TikZ, JSXGraph, and function-plot (see [[05b — Math Diagrams]])\n- [x] Vault-wide Tasks grouping and filtering\n- [ ] Screenshots in the tour due:2026-04-25 !med #docs\n\n## Tasks as an app feature\n\nThe Tasks tab is not just a renderer demo. It is a vault-wide operational view for planning and review. Use it when you want one place to see what is due, what is waiting, what is done, and where each task lives.\n\n#demo #tasks #tables\n"},{"path":"inbox/demo/04 — Math with KaTeX.md","body":"# Math with KaTeX\n\nZenNotes renders LaTeX math via KaTeX. The source stays plain markdown while preview and split mode give you readable math output.\n\n## A fast way to insert math\n\nType `/` and choose **Math block** when you want display math without typing the fence from memory.\n\n## Inline math\n\nEuler's identity is $e^{i\\pi} + 1 = 0$. \nThe area of a circle is $A = \\pi r^2$. \nA quadratic has roots $x = \\dfrac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$.\n\n## Display blocks\n\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\, dx = \\sqrt{\\pi}\n$$\n\n$$\n\\frac{\\partial}{\\partial t} \\Psi(x, t) = -\\frac{\\hbar^2}{2m} \\frac{\\partial^2}{\\partial x^2} \\Psi(x, t) + V(x)\\Psi(x, t)\n$$\n\n## Aligned equations\n\n$$\n\\begin{aligned}\n(a + b)^2 &= a^2 + 2ab + b^2 \\\\\n(a - b)^2 &= a^2 - 2ab + b^2 \\\\\na^2 - b^2 &= (a + b)(a - b)\n\\end{aligned}\n$$\n\n## Matrices\n\n$$\n\\mathbf{A} =\n\\begin{bmatrix}\n 1 & 2 & 3 \\\\\n 4 & 5 & 6 \\\\\n 7 & 8 & 9\n\\end{bmatrix}\n\\qquad\n\\det(\\mathbf{A}) = 0\n$$\n\n## Summations, limits, derivatives\n\n$$\n\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}\n\\qquad\n\\lim_{x \\to 0} \\frac{\\sin x}{x} = 1\n\\qquad\n\\frac{d}{dx} \\ln x = \\frac{1}{x}\n$$\n\n## Probability and finance\n\n$$\nP(A \\mid B) = \\frac{P(B \\mid A) P(A)}{P(B)}\n$$\n\n$$\nC = S_0 \\Phi(d_1) - K e^{-rT} \\Phi(d_2)\n$$\n\n$$\nd_1 = \\frac{\\ln(S_0 / K) + (r + \\tfrac{1}{2}\\sigma^2) T}{\\sigma \\sqrt{T}}, \\qquad d_2 = d_1 - \\sigma \\sqrt{T}\n$$\n\n## Why this matters in ZenNotes\n\n- **Edit** mode keeps the raw LaTeX visible.\n- **Split** mode is great when you want source and rendered math side by side.\n- **Preview** mode turns math-heavy notes into something closer to a paper or spec.\n- Vault text search still sees the underlying source, which makes formulas searchable as text.\n\n## Prefer Typst? An alternative math engine\n\nZenNotes can also typeset math with **Typst** instead of KaTeX. Open **Settings ▸ Editor ▸ Math renderer** and pick **Typst**; it applies in both the live editor and the reading view.\n\nTypst reads the same `$…$` and `$$…$$` blocks as **Typst markup**, not LaTeX, so each note's math is written for whichever engine you pick. The formulas here are Typst syntax: with the Math renderer set to **Typst** they render; with **KaTeX** (the default) they show as errors until you switch.\n\nInline: $x^2 + y^2 = z^2$ and $sqrt(a^2 + b^2)$.\n\n$$\nintegral_0^1 x^2 dif x = 1/3\n$$\n\n$$\nsum_(n=1)^oo 1/n^2 = pi^2/6\n$$\n\n$$\nmat(1, 2; 3, 4) quad vec(a, b, c)\n$$\n\n## What's next\n\nWhen the note needs geometry, plotted functions, or figure-quality diagrams rather than equation layout, jump to [[05b — Math Diagrams]].\n\n#demo #math #reference\n"},{"path":"inbox/demo/05 — Mermaid Diagrams.md","body":"# Mermaid diagrams\n\nMermaid fences render inline in ZenNotes. They are still just markdown code blocks on disk, so you can version them, diff them, and edit them anywhere.\n\nFor TikZ, JSXGraph, and function-plot, see [[05b — Math Diagrams]].\n\n## A fast way to insert one\n\nType `/` and choose **Code block**, then change the language to `mermaid`.\n\n## Flowchart\n\n```mermaid\nflowchart LR\n A([User types]) --> B{Vim mode?}\n B -- yes --> C[CodeMirror vim keymap]\n B -- no --> D[Standard editing]\n C --> E[Save to .md]\n D --> E\n E --> F([File on disk])\n```\n\n## Sequence diagram\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant R as Renderer\n participant M as Main process\n participant D as Disk\n\n U->>R: Type in editor\n R->>M: writeNote(path, body)\n M->>D: fs.writeFile(...)\n D-->>M: ok\n M-->>R: NoteMeta\n R-->>U: Clean tab title\n```\n\n## State diagram\n\n```mermaid\nstateDiagram-v2\n [*] --> Draft\n Draft --> Review : Submit\n Review --> Draft : Request changes\n Review --> Approved : Accept\n Approved --> Published : Ship\n Published --> Archived : 90 days\n Archived --> [*]\n```\n\n## Gantt chart\n\n```mermaid\ngantt\n title Product roadmap\n dateFormat YYYY-MM-DD\n axisFormat %b %d\n\n section Editor\n Vim motions polish :done, vim1, 2026-03-10, 5d\n Outline panel :done, out1, 2026-03-17, 3d\n Attachments preview :active, att1, 2026-04-15, 7d\n Multi-window sync : mws1, after att1, 5d\n\n section Release\n QA pass : qa1, after mws1, 3d\n Ship :milestone, rel1, after qa1, 0d\n```\n\n## Pie chart\n\n```mermaid\npie title How the day was spent\n \"Deep work\" : 45\n \"Meetings\" : 15\n \"Slack\" : 10\n \"Reading\" : 20\n \"Breaks\" : 10\n```\n\n## Vault map\n\n```mermaid\ngraph TB\n subgraph Lifecycle\n Q[Quick Notes]\n I[Inbox]\n A[Archive]\n T[Trash]\n end\n Q --> I\n I --> A\n I --> T\n A --> I\n T --> I\n```\n\n## Working with diagrams in the app\n\n- **Split** mode is usually the sweet spot: raw source on one side, rendered diagram on the other.\n- Diagrams are still searchable because the source fence lives in the note body.\n- If Mermaid syntax breaks, ZenNotes falls back to showing the source block, which makes failures debuggable instead of mysterious.\n\n## What's next\n\nStay in diagram mode with [[05b — Math Diagrams]] if you want interactive geometry, coordinate figures, or compact function plots.\n\n#demo #mermaid #diagrams\n"},{"path":"inbox/demo/05b — Math Diagrams.md","body":"# Math diagrams — TikZ, JSXGraph, and function-plot\n\nBeyond Mermaid (see [[05 — Mermaid Diagrams]]) and KaTeX (see [[04 — Math with KaTeX]]), ZenNotes renders three more diagram types from plain fenced code blocks. Each one shines at a different job.\n\nSwitch to **Preview** or **Split** mode to see them rendered. The source stays plain markdown on disk.\n\n---\n\n## TikZ — figure-quality math diagrams\n\nUse when you want paper-grade vector figures: coordinate systems, geometry, commutative diagrams, automata, trees, plots. The full TikZ + pgfplots toolchain compiles on-device via WebAssembly — no network, no LaTeX install.\n\n### A parabola with axes\n\n```tikz\n\\begin{tikzpicture}\n \\draw[->, thick] (-2.2,0) -- (2.2,0) node[right] {$x$};\n \\draw[->, thick] (0,-0.5) -- (0,4.5) node[above] {$y$};\n \\draw[domain=-2:2, smooth, thick, blue] plot (\\x,{\\x*\\x});\n \\node[blue, above right] at (1.4, 1.96) {$y = x^2$};\n\\end{tikzpicture}\n```\n\n### A triangle with labelled vertices\n\n```tikz\n\\begin{tikzpicture}\n \\coordinate[label=below left:$A$] (A) at (0,0);\n \\coordinate[label=below right:$B$] (B) at (4,0);\n \\coordinate[label=above:$C$] (C) at (1.5,3);\n \\draw[thick] (A) -- (B) -- (C) -- cycle;\n \\draw[dashed] (C) -- ($ (A)!(C)!(B) $) node[pos=0.5, right] {$h$};\n\\end{tikzpicture}\n```\n\n### A small commutative diagram\n\n```tikz\n\\begin{tikzpicture}[node distance=2.2cm, every node/.style={font=\\small}]\n \\node (A) {$A$};\n \\node (B) [right of=A] {$B$};\n \\node (C) [below of=A] {$C$};\n \\node (D) [right of=C] {$D$};\n \\draw[->] (A) -- node[above] {$f$} (B);\n \\draw[->] (A) -- node[left] {$g$} (C);\n \\draw[->] (B) -- node[right] {$h$} (D);\n \\draw[->] (C) -- node[below] {$k$} (D);\n\\end{tikzpicture}\n```\n\n---\n\n## JSXGraph — interactive geometry and plots\n\nUse when you want the diagram to be **draggable** and **live**. Points move, sliders animate, curves reflow. Configuration is a small JSON object — no JavaScript required.\n\nEach object takes a `type` (the JSXGraph element name) and `args` (the element's constructor arguments). Assign an `id` to reference an object from a later one using `\"@id\"` — useful for attaching points to curves, for example.\n\n### Sine wave with a point on the curve\n\nJSXGraph's `functiongraph` evaluates string expressions with its built-in **JessieCode** parser — so write `sin(x)`, `cos(x)`, `x^2`, `exp(x)`, etc. directly (no `Math.` prefix).\n\n```jsxgraph\n{\n \"boundingbox\": [-6.5, 1.6, 6.5, -1.6],\n \"axis\": true,\n \"objects\": [\n {\n \"id\": \"curve\",\n \"type\": \"functiongraph\",\n \"args\": [\"sin(x)\"],\n \"attributes\": { \"strokeColor\": \"#6caedf\", \"strokeWidth\": 2 }\n },\n {\n \"type\": \"glider\",\n \"args\": [1, 0, \"@curve\"],\n \"attributes\": {\n \"name\": \"P\",\n \"size\": 4,\n \"strokeColor\": \"#d35e0c\",\n \"fillColor\": \"#d35e0c\"\n }\n }\n ]\n}\n```\n\nDrag `P` along the curve.\n\n### Unit circle with a labelled point\n\n```jsxgraph\n{\n \"boundingbox\": [-1.6, 1.6, 1.6, -1.6],\n \"axis\": true,\n \"width\": 360,\n \"height\": 360,\n \"objects\": [\n {\n \"type\": \"circle\",\n \"args\": [[0, 0], 1],\n \"attributes\": { \"strokeColor\": \"#945e80\" }\n },\n {\n \"type\": \"point\",\n \"args\": [0.7, 0.7141],\n \"attributes\": {\n \"name\": \"Q\",\n \"fillColor\": \"#6c782e\",\n \"strokeColor\": \"#6c782e\"\n }\n }\n ]\n}\n```\n\n### Two lines and their intersection\n\n```jsxgraph\n{\n \"boundingbox\": [-5, 5, 5, -5],\n \"axis\": true,\n \"objects\": [\n { \"id\": \"A\", \"type\": \"point\", \"args\": [-3, -2], \"attributes\": { \"name\": \"A\" } },\n { \"id\": \"B\", \"type\": \"point\", \"args\": [ 3, 2], \"attributes\": { \"name\": \"B\" } },\n { \"id\": \"C\", \"type\": \"point\", \"args\": [-3, 2], \"attributes\": { \"name\": \"C\" } },\n { \"id\": \"D\", \"type\": \"point\", \"args\": [ 3, -2], \"attributes\": { \"name\": \"D\" } },\n {\n \"id\": \"L1\",\n \"type\": \"line\",\n \"args\": [\"@A\", \"@B\"],\n \"attributes\": { \"strokeColor\": \"#45707a\" }\n },\n {\n \"id\": \"L2\",\n \"type\": \"line\",\n \"args\": [\"@C\", \"@D\"],\n \"attributes\": { \"strokeColor\": \"#c14a4a\" }\n },\n {\n \"type\": \"intersection\",\n \"args\": [\"@L1\", \"@L2\", 0],\n \"attributes\": { \"name\": \"X\", \"size\": 4, \"fillColor\": \"#b47109\" }\n }\n ]\n}\n```\n\nDrag any of `A`–`D` and the intersection follows.\n\n---\n\n## function-plot — quick Cartesian plots\n\nSmallest and simplest of the three. Give it functions, get a plot. Great for calculus-style notes and quick sanity checks.\n\nThe fence body is the options object passed to [function-plot](https://mauriciopoppe.github.io/function-plot/). Expression syntax is standard JavaScript math — `Math.PI`, `Math.sin(x)`, etc. — plus the `x^2` shorthand for powers.\n\n### Several functions on one axis\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"xAxis\": { \"domain\": [-6.28, 6.28] },\n \"grid\": true,\n \"data\": [\n { \"fn\": \"sin(x)\", \"color\": \"#45707a\" },\n { \"fn\": \"cos(x)\", \"color\": \"#c14a4a\" },\n { \"fn\": \"x / 3.14159265\", \"color\": \"#6c782e\" }\n ]\n}\n```\n\n### A derivative annotation\n\nHover the curve — the tangent slope updates live.\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-2, 8] },\n \"xAxis\": { \"domain\": [-3, 3] },\n \"grid\": true,\n \"data\": [\n {\n \"fn\": \"x^2\",\n \"derivative\": { \"fn\": \"2 * x\", \"updateOnMouseMove\": true },\n \"color\": \"#945e80\"\n }\n ]\n}\n```\n\n### A parametric curve\n\n```function-plot\n{\n \"xAxis\": { \"domain\": [-1.5, 1.5] },\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"grid\": true,\n \"data\": [\n {\n \"graphType\": \"polyline\",\n \"fnType\": \"parametric\",\n \"x\": \"cos(t)\",\n \"y\": \"sin(t)\",\n \"range\": [0, 6.283],\n \"color\": \"#b47109\"\n }\n ]\n}\n```\n\n---\n\n## When to reach for which\n\n| You want… | Use |\n| ---------------------------------------------------------------- | ------------------------------------------- |\n| Paper-grade static figure, TikZ muscle-memory, LaTeX portability | **TikZ** |\n| Interactive geometry, draggable points, geometry theorems | **JSXGraph** |\n| Quick plot of a few functions, minimal config | **function-plot** |\n| Flow / sequence / state / gantt / ER diagram | **Mermaid** (see [[05 — Mermaid Diagrams]]) |\n| Inline formulas, display equations | **KaTeX** (see [[04 — Math with KaTeX]]) |\n\n#demo #math #diagrams #tikz #jsxgraph #function-plot\n"},{"path":"inbox/demo/06 — Callouts and Footnotes.md","body":"# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n"},{"path":"inbox/demo/07 — Wiki Links and Tags.md","body":"# Wiki links, tags, backlinks, and search\n\nThese features turn a folder of markdown files into a navigable vault.\n\n## Wiki links\n\nPoint at other notes with `[[double brackets]]`. ZenNotes resolves them by note title, case-insensitively.\n\n- Shortest form: [[01 — Markdown Basics]]\n- Custom display text: [[11 — Workspace, Search, and Views|workspace guide]]\n- Missing note: [[A Future Note]] — opening it offers to create the note\n\nYou can follow links with the mouse or keyboard:\n\n- in Vim mode, put the cursor on a link and press `gd`\n- markdown links and wikilinks both work\n- PDFs can open directly into the reference pane\n\n## Tags\n\nTags are plain inline text. They start with `#` and become searchable structure.\n\nThis demo folder uses tags like:\n\n- #demo\n- #reference\n- #tasks\n- #vim\n- #search\n- #workspace\n\nThe **Tags** view lets you browse notes matching one or more selected tags in a dedicated main-pane list.\n\n## Connections\n\nThe **Connections** panel helps you inspect:\n\n- outbound links from the current note\n- backlinks into the current note\n- unresolved link targets that still need a note\n\nThis is especially useful when you are writing specs, research notes, or project docs and want context without leaving the active note.\n\n## Search modes\n\nZenNotes has two distinct searches:\n\n### Note search\n\n- `⌘P` opens the note search palette\n- `Space f` opens the same search in Vim mode\n- this search matches note titles and paths\n\n### Vault text search\n\n- `Space s t` opens vault text search\n- it searches matching text lines across **Inbox**, **Quick Notes**, and **Archive**\n- selecting a result opens the note and jumps to the matched line\n\nVault text search can run on different backends:\n\n- **Auto** prefers `fzf`, then `ripgrep`, then built-in\n- **Built-in** keeps everything inside ZenNotes\n- **ripgrep** and **fzf** can be chosen explicitly\n- custom binary paths can be configured in **Settings**\n- the app shows the resolved runtime backend so you can see what is actually being used\n\n## Graph of this tour\n\n```mermaid\ngraph LR\n A[[00 — Start Here]]\n A --> B[[01 — Markdown Basics]]\n A --> C[[02 — Code Blocks]]\n A --> D[[03 — Tables and Task Lists]]\n A --> E[[04 — Math with KaTeX]]\n A --> F[[05 — Mermaid Diagrams]]\n A --> G[[05b — Math Diagrams]]\n A --> H[[06 — Callouts and Footnotes]]\n A --> I[[07 — Wiki Links and Tags]]\n A --> J[[08 — Daily Notes]]\n A --> K[[09 — Vim Cheat Sheet]]\n A --> L[[10 — Ideas and Tasks]]\n A --> M[[11 — Workspace, Search, and Views]]\n A --> N[[12 — Settings and Keymaps]]\n A --> O[[13 — Commands, Help, and Demo Tour]]\n A --> P[[14 — Reference Pane and Floating Windows]]\n A --> Q[[15 — Search Backends and Fuzzy Workflows]]\n```\n\n#demo #reference #search #links\n"},{"path":"inbox/demo/08 — Daily Notes.md","body":"---\ntitle: 2026-04-16\ndate: 2026-04-16\ntags: [daily, log, demo]\n---\n\n# Thursday, 2026-04-16\n\n> [!tip] Pattern\n> A daily note is still just a `.md` file. Keep it under `inbox/daily/`, `quick/`, or wherever your vault makes sense. If you name it `YYYY-MM-DD.md`, it sorts chronologically without extra tooling.\n\n## Why daily notes fit ZenNotes well\n\n- they stay file-based and sync-friendly\n- they pair naturally with quick capture\n- they work well with tasks, tags, and links\n- reopening the app restores your tabs, panes, and window bounds, so an active daily workflow is easy to resume\n\n## Agenda\n\n- [ ] Morning: triage [[10 — Ideas and Tasks]]\n- [ ] 10:00 — design review\n- [ ] 12:00 — lunch\n- [x] 14:00 — code-freeze prep\n- [ ] Evening: reading — Seeing Like a State, chapter 3\n\n## Quick capture and dates\n\nQuick Notes are for fast capture. From there you can:\n\n- keep the note in Quick Notes\n- move it into Inbox\n- archive it later\n- trash it with confirmation if it is no longer useful\n\nDate helpers are also built in:\n\n- type `@` to insert **Today**, **Yesterday**, or **Tomorrow**\n- the inserted value is an ISO date like `2026-04-16`\n- ISO dates stay readable, sortable, and easy to search\n\nExamples:\n\n- Review due @today\n- Follow up on search backend docs @tomorrow\n- Closed the previous thread @yesterday\n\n## Log\n\n- Shipped the vault text search backend picker.\n- Updated the demo vault so it covers the current product surface.\n- Verified that session restore brings back the working layout after relaunch.\n\n## Wins\n\n- The same note works in edit, split, or preview mode.\n- Tasks here show up in the vault-wide Tasks view.\n- Links here also show up in Connections.\n\n## Follow-ups\n\n- [ ] Add a sample PDF so the reference-pane flow is demonstrated with a real file.\n- [ ] Add more screenshots for the search palette.\n- [ ] Refine the help text for view-specific ex prompts.\n\n## Notes for tomorrow\n\n- [ ] Carry over open tasks from [[03 — Tables and Task Lists]]\n- [ ] Review [[12 — Settings and Keymaps]] for any missing personalization features\n\n#daily #log #demo\n"},{"path":"inbox/demo/09 — Vim Cheat Sheet.md","body":"# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading at the cursor |\n| `zo` | Unfold the heading at the cursor |\n| `zM` | Fold all headings |\n| `zR` | Unfold all headings |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 — Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n"},{"path":"inbox/demo/10 — Ideas and Tasks.md","body":"# Ideas and tasks — a realistic note\n\nThis is the kind of note most real users end up writing: prose, todos, links, snippets, diagrams, and operational context all mixed together. It shows how ZenNotes features compose instead of living in isolated demos.\n\n> [!note]\n> Status as of 2026-04-16. Use this note to test search, outline, connections, Tasks, and split view in one place.\n\n## Open questions\n\n- [ ] Should attachment previews appear inline for PDFs by default?\n- [ ] Is the built-in text-search backend fast enough on large vaults when neither `fzf` nor `ripgrep` is available?\n- [ ] Do we expose tag renaming from the UI, or keep it intentionally file-grep first?\n\n## Working notes\n\n- Quick capture starts in **Quick Notes**, but anything important should graduate into **Inbox**.\n- Cold notes belong in **Archive**, which now opens as a dedicated main-pane list view.\n- Deleted notes should go through **Trash**, where restore and permanent delete are separated on purpose.\n- If tabs are hidden, `Space o` or `:buffers` becomes the fastest way to recover the current working set.\n\n## Now\n\n- [ ] Add a sample PDF + image to the tour so [[06 — Callouts and Footnotes]] can illustrate attachments and reference-pane workflows.\n- [x] Document the Tasks tab behavior in [[03 — Tables and Task Lists]].\n- [ ] Collect feedback on [[09 — Vim Cheat Sheet]] now that keymaps are configurable.\n- [ ] Confirm the search backend badge is visible enough in the vault text search palette.\n\n## Shipped\n\n- [x] Vault text search can use **Auto**, **Built-in**, **ripgrep**, or **fzf**.\n- [x] Custom binary paths can be configured when `rg` or `fzf` live outside `PATH`.\n- [x] Settings now show the resolved runtime backend instead of only the requested one.\n- [x] Archive and Trash both behave as list-style built-in tabs instead of sidebar dump zones.\n\n## Cross-references\n\n- Tour index: [[00 — Start Here]]\n- Search and links: [[07 — Wiki Links and Tags]]\n- Workspace guide: [[11 — Workspace, Search, and Views]]\n- Settings and keymaps: [[12 — Settings and Keymaps]]\n\n## A snippet I keep forgetting\n\nConverting a buffer to hex in Node:\n\n```ts\nimport { randomBytes } from 'node:crypto'\n\nconst buf = randomBytes(16)\nconsole.log(buf.toString('hex'))\n```\n\nConverting back:\n\n```ts\nconst hex = '01020304abcdef'\nconst buf = Buffer.from(hex, 'hex')\n```\n\n## Rough architecture sketch\n\n```mermaid\nflowchart TB\n subgraph Main\n V[Vault I/O]\n W[Watcher]\n T[Task scanner]\n S[Vault text search]\n end\n subgraph Renderer\n E[Editor]\n SB[Sidebar]\n P[Preview]\n O[Outline]\n C[Connections]\n end\n E <-->|IPC| V\n SB -->|IPC| V\n P -->|IPC| V\n O --> E\n C --> E\n V --> T\n V --> S\n W -->|events| V\n```\n\n## A little math\n\nThe rough cost model people keep re-deriving:\n\n$$\nT \\approx 3 \\cdot t \\cdot \\frac{m}{\\text{bandwidth}}\n$$\n\n## Workflow checklist\n\n- [ ] Try this note in **Edit**, **Split**, and **Preview**\n- [ ] Open the **outline** and jump to \"Workflow checklist\"\n- [ ] Open **Connections** and inspect backlinks\n- [ ] Search for `backend` with `Space s t`\n- [ ] Toggle **Zen mode**\n\n#demo #tasks #planning #workspace\n"},{"path":"inbox/demo/11 — Workspace, Search, and Views.md","body":"# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n"},{"path":"inbox/demo/12 — Settings and Keymaps.md","body":"# Settings and keymaps\n\nZenNotes is keyboard-first by default, but it is not rigid anymore. Settings now cover both presentation and behavior.\n\n## Appearance\n\nFrom Settings you can tune:\n\n- theme family\n- light or dark mode\n- theme variant or contrast\n- dark sidebar treatment\n\nThe point is to keep the app comfortable for long sessions without changing the underlying note files.\n\n## Editor behavior\n\nKey editor settings include:\n\n- Vim mode on or off\n- leader key hints on or off\n- timed vs sticky leader hints\n- leader hint duration\n- live preview\n- note tabs\n- word wrap\n- PDF behavior in edit mode\n- date-titled Quick Notes\n\n## Vault text search backends\n\nVault text search can be powered by:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\nYou can also set explicit binary paths for `rg` and `fzf` in case they live outside your normal `PATH`.\n\nZenNotes now shows:\n\n- what tools are available\n- what backend is configured\n- what backend is actually being used at runtime\n\nThat matters because **Auto** can fall back, and explicit backends can also fall back when the configured binary path is missing.\n\n## Typography and layout\n\nYou can tune:\n\n- interface font\n- reading font\n- monospace font\n- editor and preview font size\n- line height\n- reading width\n- editor width\n- centered vs left-aligned content\n- line numbers\n\nThese are workflow settings, not note-format settings. The markdown file stays the same.\n\n## Keymaps\n\nKeymaps are now configurable from inside the app:\n\n- global shortcuts\n- leader sequences\n- pane-prefix motions\n- Vim-specific editor actions\n- list and view navigation\n\nThat means you can remap things like:\n\n- search notes\n- search vault text\n- toggle Zen mode\n- pane movement\n- fold motions\n- leader flows such as `Space s t`\n\nMulti-step sequences are supported, so the keymap system can handle more than single shortcuts.\n\n## Vault and About\n\nThe rest of Settings handles the vault and app identity:\n\n- reveal or change the vault location\n- inspect the app version\n- see the About section\n- find the Lumary Labs link\n- remember that Settings save automatically on this device\n\n## Practical advice\n\nIf you are learning the app:\n\n1. keep Vim mode on\n2. enable leader hints\n3. leave search backend on **Auto**\n4. only start remapping after the defaults feel familiar\n\nThat gives you the clearest path through the built-in help, demos, and keyboard flows.\n\nFor a deeper walkthrough of runtime backend selection, fallbacks, and fuzzy content search behavior, see [[15 — Search Backends and Fuzzy Workflows]].\n\n#demo #settings #keymaps #reference\n"},{"path":"inbox/demo/13 — Commands, Help, and Demo Tour.md","body":"# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo file at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo file\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n"},{"path":"inbox/demo/14 — Reference Pane and Floating Windows.md","body":"# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local files\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n"},{"path":"inbox/demo/15 — Search Backends and Fuzzy Workflows.md","body":"# Search backends and fuzzy workflows\n\nZenNotes has two different search surfaces, and the deeper one can be powered by different backends.\n\n## Two searches, two jobs\n\n### Note search\n\nUse when you want to find a note by title or path:\n\n- `⌘P`\n- `Space f`\n\nThis is the fastest way to jump to a file you already roughly know.\n\n### Vault text search\n\nUse when you want to find matching text inside note bodies:\n\n- `Space s t`\n\nThis searches across note content and jumps directly to the matching line when you open a result.\n\n## Backends\n\nVault text search can run on:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\n### Auto\n\n`Auto` prefers:\n\n1. `fzf`\n2. `ripgrep`\n3. built-in fallback\n\nThat makes the app adapt to what is installed on the machine.\n\n### Built-in\n\nUse this when you want:\n\n- zero external dependencies\n- predictable behavior across machines\n- a search path that always exists even when no tools are installed\n\n### ripgrep\n\nUse this when you want:\n\n- strong plain-text search performance\n- system-level tooling you may already use outside the app\n- a backend that is familiar to terminal users\n\n### fzf\n\nUse this when you want:\n\n- terminal-style fuzzy matching behavior\n- ranking that feels close to launcher workflows\n- an external backend often used by Vim and Neovim users\n\n## Custom binary paths\n\nIf `rg` or `fzf` are not in your normal `PATH`, ZenNotes lets you point to them directly from Settings.\n\nExamples:\n\n- `/opt/homebrew/bin/rg`\n- `/opt/homebrew/bin/fzf`\n- `/usr/local/bin/rg`\n\nBlank means “use whatever is on PATH”.\n\n## Runtime backend vs configured backend\n\nZenNotes shows:\n\n- what you configured\n- what tools are available\n- what backend is actually being used\n\nThat distinction matters because:\n\n- `Auto` may resolve differently on different machines\n- explicit `ripgrep` or `fzf` settings can still fall back if the binary path is invalid\n\n## Search result behavior\n\nVault text search is designed to be navigational, not just informational:\n\n- results stay keyboard navigable\n- the active row stays in view while you move\n- the matching text is highlighted in the result\n- opening a result moves the cursor to the match in the note\n\nThis makes it feel more like a picker than a grep dump.\n\n## Good habits\n\n- use note search when you know the file\n- use vault text search when you only know the phrase\n- leave the backend on **Auto** unless you have a reason to force one\n- configure explicit binary paths if your tools live outside `PATH`\n\n## Related notes\n\n- [[07 — Wiki Links and Tags]] for search in the context of notes, tags, and links\n- [[11 — Workspace, Search, and Views]] for where these pickers fit into the app\n- [[12 — Settings and Keymaps]] for changing the backend and remapping the shortcut\n\n#demo #search #fzf #ripgrep #reference\n"}],"assets":[{"path":"zennotes-demo-card.svg","body":"\n \n \n \n \n \n \n \n \n \n \n \n \n \n DEMO\n ZenNotes Demo\n Local files, keyboard-first flows, and markdown-friendly structure.\n \n \n \n \n \n \n \n SEE ALSO: HELP, SEARCH, OUTLINE, TASKS, QUICK NOTES\n\n"}]} \ No newline at end of file diff --git a/apps/server/internal/vault/demo.go b/apps/server/internal/vault/demo.go deleted file mode 100644 index 07785fb1..00000000 --- a/apps/server/internal/vault/demo.go +++ /dev/null @@ -1,119 +0,0 @@ -package vault - -import ( - _ "embed" - "encoding/json" - "os" - "path/filepath" - "strings" -) - -//go:embed demo-tour.json -var demoTourJSON []byte - -type demoFile struct { - Path string `json:"path"` - Body string `json:"body"` -} - -type demoTour struct { - Notes []demoFile `json:"notes"` - Assets []demoFile `json:"assets"` -} - -// DemoTourResult mirrors shared/ipc.ts VaultDemoTourResult. -type DemoTourResult struct { - NotePaths []string `json:"notePaths"` - AssetPaths []string `json:"assetPaths"` -} - -func loadDemoTour() (*demoTour, error) { - tour := &demoTour{} - if err := json.Unmarshal(demoTourJSON, tour); err != nil { - return nil, err - } - return tour, nil -} - -// GenerateDemoTour seeds the vault with the built-in tour notes and -// the demo attachment. Existing files are overwritten. -func (v *Vault) GenerateDemoTour() (DemoTourResult, error) { - v.mu.Lock() - defer v.mu.Unlock() - tour, err := loadDemoTour() - if err != nil { - return DemoTourResult{}, err - } - result := DemoTourResult{NotePaths: []string{}, AssetPaths: []string{}} - for _, note := range tour.Notes { - abs, err := SafeJoin(v.root, note.Path) - if err != nil { - return DemoTourResult{}, err - } - if err := os.MkdirAll(filepath.Dir(abs), v.dirMode); err != nil { - return DemoTourResult{}, err - } - if err := os.WriteFile(abs, []byte(note.Body), v.fileMode); err != nil { - return DemoTourResult{}, err - } - result.NotePaths = append(result.NotePaths, filepath.ToSlash(note.Path)) - } - for _, asset := range tour.Assets { - abs, err := SafeJoin(v.root, asset.Path) - if err != nil { - return DemoTourResult{}, err - } - if err := os.MkdirAll(filepath.Dir(abs), v.dirMode); err != nil { - return DemoTourResult{}, err - } - if err := os.WriteFile(abs, []byte(asset.Body), v.fileMode); err != nil { - return DemoTourResult{}, err - } - result.AssetPaths = append(result.AssetPaths, filepath.ToSlash(asset.Path)) - } - return result, nil -} - -// RemoveDemoTour deletes the demo notes + asset if they exist. Also -// removes empty parent directories under inbox/demo. -func (v *Vault) RemoveDemoTour() (DemoTourResult, error) { - v.mu.Lock() - defer v.mu.Unlock() - tour, err := loadDemoTour() - if err != nil { - return DemoTourResult{}, err - } - result := DemoTourResult{NotePaths: []string{}, AssetPaths: []string{}} - removedDirs := map[string]bool{} - for _, note := range tour.Notes { - abs, err := SafeJoin(v.root, note.Path) - if err != nil { - continue - } - if err := os.Remove(abs); err == nil { - result.NotePaths = append(result.NotePaths, filepath.ToSlash(note.Path)) - removedDirs[filepath.Dir(abs)] = true - } - } - for _, asset := range tour.Assets { - abs, err := SafeJoin(v.root, asset.Path) - if err != nil { - continue - } - if err := os.Remove(abs); err == nil { - result.AssetPaths = append(result.AssetPaths, filepath.ToSlash(asset.Path)) - } - } - for dir := range removedDirs { - // Walk up from each removed-note directory and rmdir empties, - // stopping at the vault root. - d := dir - for strings.HasPrefix(d, v.root) && d != v.root { - if err := os.Remove(d); err != nil { - break - } - d = filepath.Dir(d) - } - } - return result, nil -} diff --git a/apps/server/internal/vault/parse.go b/apps/server/internal/vault/parse.go deleted file mode 100644 index 4b9a6d00..00000000 --- a/apps/server/internal/vault/parse.go +++ /dev/null @@ -1,711 +0,0 @@ -package vault - -import ( - "regexp" - "strings" - "unicode" -) - -// Regexes below mirror the TS extractors in src/main/vault.ts. They are -// intentionally the same shape so the extracted metadata matches the -// desktop build byte-for-byte for the common cases. - -var ( - fenceLineRe = regexp.MustCompile("^[ \t]*(`{3,}|~{3,})(.*)$") - inlineCodeRe = regexp.MustCompile("`[^`\n]*`") - tagRe = regexp.MustCompile(`(?:^|\s)#(\p{L}[\p{L}\d_/-]*)`) - wikilinkRe = regexp.MustCompile(`(!?)\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]`) - linkRe = regexp.MustCompile(`(!?)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)`) - embedRe = regexp.MustCompile(`!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]`) - frontmatterRe = regexp.MustCompile(`(?s)\A---\r?\n(.*?)\r?\n---\r?\n?`) - headingRe = regexp.MustCompile(`(?m)^#{1,6}\s+`) - imageMdRe = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`) - mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`) - mdEmbedAltRe = regexp.MustCompile(`!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`) - mdWikiAltRe = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`) - markupTrimRe = regexp.MustCompile(`[*_~>]+`) - wsCollapseRe = regexp.MustCompile(`\s+`) -) - -var attachmentExts = map[string]bool{ - ".apng": true, ".avif": true, ".gif": true, ".jpeg": true, ".jpg": true, - ".png": true, ".svg": true, ".webp": true, ".pdf": true, - ".aac": true, ".flac": true, ".m4a": true, ".mp3": true, ".ogg": true, ".wav": true, - ".m4v": true, ".mov": true, ".mp4": true, ".ogv": true, ".webm": true, -} - -// stripCodeContent blanks fenced and inline code so the tag/link/excerpt -// scanners never read code as content. Fence detection is line-based and -// indentation-tolerant: a fence nested under a list item is still a code block, -// so its contents (e.g. a C "#include" line) must not be scanned. A -// column-0-anchored regex missed indented fences and leaked them as tags (#293). -// Mirrors stripCodeContent in apps/desktop/src/main/vault.ts and -// packages/app-core/src/lib/tags.ts — keep the three in sync. -func stripCodeContent(body string) string { - if !strings.Contains(body, "`") && !strings.Contains(body, "~") { - return body - } - lines := strings.Split(body, "\n") - inFence := false - var fenceChar byte - fenceLen := 0 - for i, line := range lines { - if m := fenceLineRe.FindStringSubmatch(line); m != nil { - marker := m[1] - char := marker[0] - rest := m[2] - if !inFence { - // A backtick fence's info string may not contain a backtick (CommonMark). - if char == '~' || !strings.Contains(rest, "`") { - inFence = true - fenceChar = char - fenceLen = len(marker) - lines[i] = " " - continue - } - } else if char == fenceChar && len(marker) >= fenceLen && strings.TrimSpace(rest) == "" { - inFence = false - lines[i] = " " - continue - } - } - if inFence { - lines[i] = " " - } - } - out := strings.Join(lines, "\n") - out = inlineCodeRe.ReplaceAllString(out, " ") - return out -} - -// ExtractTags returns unique tags from first-class frontmatter `tags` and inline #tags. -func ExtractTags(body string) []string { - seen := map[string]bool{} - out := []string{} - if m := frontmatterRe.FindStringSubmatch(body); len(m) >= 2 { - fm := parseTaskFrontmatter(m[1]) - for _, raw := range fm["tags"] { - // A bare scalar splits on commas and whitespace: `tags: daily, work` - // is two tags and a tag can contain neither. Kept in sync with - // frontmatterTags in packages/shared-domain/src/frontmatter.ts. - for _, part := range strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || unicode.IsSpace(r) - }) { - tag := strings.TrimPrefix(part, "#") - if tag != "" && !seen[tag] { - seen[tag] = true - out = append(out, tag) - } - } - } - } - - markdownBody := frontmatterRe.ReplaceAllString(body, "") - if !strings.Contains(markdownBody, "#") { - return out - } - stripped := stripCodeContent(markdownBody) - for _, m := range tagRe.FindAllStringSubmatch(stripped, -1) { - if len(m) >= 2 { - tag := m[1] - if !seen[tag] { - seen[tag] = true - out = append(out, tag) - } - } - } - return out -} - -// ExtractWikilinks returns unique [[wikilink]] targets, ignoring code. -func ExtractWikilinks(body string) []string { - if !strings.Contains(body, "[[") { - return []string{} - } - stripped := stripCodeContent(body) - seen := map[string]bool{} - out := []string{} - for _, m := range wikilinkRe.FindAllStringSubmatch(stripped, -1) { - if len(m) >= 3 { - bang := m[1] - target := strings.TrimSpace(m[2]) - if target == "" { - continue - } - if bang == "!" && localAssetTargetKind(target) != "" { - continue - } - if !seen[target] { - seen[target] = true - out = append(out, target) - } - } - } - return out -} - -// BodyHasLocalAsset is the same cheap heuristic as the TS version. -func BodyHasLocalAsset(body string) bool { - if !strings.Contains(body, "](") && !strings.Contains(body, "![[") { - return false - } - stripped := stripCodeContent(body) - for _, m := range linkRe.FindAllStringSubmatch(stripped, -1) { - if len(m) < 3 { - continue - } - href := strings.TrimSpace(m[2]) - if href == "" || strings.HasPrefix(href, "#") || strings.HasPrefix(href, "//") { - continue - } - if matched, _ := regexpMatchScheme(href); matched { - continue - } - if localAssetTargetKind(href) != "" { - return true - } - } - for _, m := range embedRe.FindAllStringSubmatch(stripped, -1) { - if len(m) < 2 { - continue - } - if localAssetTargetKind(strings.TrimSpace(m[1])) != "" { - return true - } - } - return false -} - -var schemeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z\d+.\-]*:`) - -func regexpMatchScheme(href string) (bool, error) { - return schemeRe.MatchString(href), nil -} - -// BuildExcerpt makes a short plaintext preview from markdown. -func BuildExcerpt(body string) string { - withoutFront := body - if strings.HasPrefix(body, "---\n") { - withoutFront = frontmatterRe.ReplaceAllString(body, "") - } - text := stripCodeContent(withoutFront) - if strings.Contains(text, "](") { - text = imageMdRe.ReplaceAllString(text, " ") - text = mdLinkRe.ReplaceAllString(text, "$1") - } - if strings.Contains(text, "![[") { - text = mdEmbedAltRe.ReplaceAllStringFunc(text, func(s string) string { - m := mdEmbedAltRe.FindStringSubmatch(s) - if len(m) >= 3 && m[2] != "" { - return m[2] - } - if len(m) >= 2 { - return m[1] - } - return "" - }) - } - if strings.Contains(text, "[[") { - text = mdWikiAltRe.ReplaceAllStringFunc(text, func(s string) string { - m := mdWikiAltRe.FindStringSubmatch(s) - if len(m) >= 3 && m[2] != "" { - return m[2] - } - if len(m) >= 2 { - return m[1] - } - return "" - }) - } - if strings.Contains(text, "#") { - text = headingRe.ReplaceAllString(text, "") - } - if strings.ContainsAny(text, "*_~>") { - text = markupTrimRe.ReplaceAllString(text, "") - } - text = wsCollapseRe.ReplaceAllString(text, " ") - text = strings.TrimSpace(text) - if len(text) > 220 { - text = text[:220] - } - return text -} - -func localAssetTargetKind(target string) string { - clean := target - if i := strings.IndexAny(clean, "#?"); i >= 0 { - clean = clean[:i] - } - dot := strings.LastIndexByte(clean, '.') - if dot < 0 { - return "" - } - ext := strings.ToLower(clean[dot:]) - if attachmentExts[ext] { - return ext - } - return "" -} - -// --- Task parsing (mirrors shared/tasks.ts parseTasksFromBody) --- - -var ( - taskLineRe = regexp.MustCompile(`^(\s*(?:[-*+]|\d+\.)\s+)\[( |x|X|>|-|/)\](.*)$`) - inlineDueRe = regexp.MustCompile(`(?i)(?:^|\s)due:\s*(\S+)`) - inlinePriority = regexp.MustCompile(`(?i)(?:^|\s)!(high|med|medium|low|h|m|l)\b`) - inlineWaitingRe = regexp.MustCompile(`(?i)(?:^|\s)@waiting\b`) - inlineFieldRe = regexp.MustCompile(`(?i)(?:^|\s)@([a-z][a-z0-9_-]*):([\p{L}\d][\p{L}\d/_-]*)`) - inlineTagRe = regexp.MustCompile(`(?:^|\s)#([\p{L}\d][\p{L}\d/_\-]*)`) - isoDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) -) - -func isValidIsoDate(s string) bool { - if !isoDateRe.MatchString(s) { - return false - } - return true -} - -func normalizePriority(raw string) string { - v := strings.ToLower(strings.TrimSpace(raw)) - switch v { - case "high", "h": - return "high" - // `normal` is the TaskNotes default priority; map it onto ZenNotes' `med`. - // The inline `!prio` regex never emits `normal`, so inline parsing is - // unaffected; only frontmatter file-tasks reach this arm. - case "med", "medium", "normal", "m": - return "med" - case "low", "l": - return "low" - } - return "" -} - -type noteDefaults struct { - Due string - Priority string - Status string - TasksMode string -} - -// Note-level participation in the Tasks system, from the frontmatter `tasks:` -// key (#458). Mirrors noteTasksMode in packages/shared-domain/src/tasks.ts; -// keep the accepted values byte-identical. No runtime in this app types YAML -// scalars, so `tasks: false` arrives as the string "false"; matching is exact -// string comparison after lower-casing, anything unrecognized falls back to -// "all" (the pre-#458 behavior). -const ( - tasksModeAll = "all" - tasksModeNoteOnly = "note-only" - tasksModeNone = "none" -) - -func noteTasksMode(val string) string { - switch strings.ToLower(strings.TrimSpace(val)) { - case "false", "off": - return tasksModeNone - case "note": - return tasksModeNoteOnly - } - return tasksModeAll -} - -func parseNoteDefaults(body string) noteDefaults { - m := frontmatterRe.FindStringSubmatch(body) - if len(m) < 2 { - return noteDefaults{TasksMode: tasksModeAll} - } - d := noteDefaults{TasksMode: tasksModeAll} - for _, line := range strings.Split(m[1], "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - colon := strings.IndexByte(trimmed, ':') - if colon < 1 { - continue - } - key := strings.ToLower(strings.TrimSpace(trimmed[:colon])) - val := unquote(strings.TrimSpace(trimmed[colon+1:])) - switch key { - case "due": - if isValidIsoDate(val) { - d.Due = val - } - case "priority": - if p := normalizePriority(val); p != "" { - d.Priority = p - } - case "status": - d.Status = strings.ToLower(val) - case "tasks": - d.TasksMode = noteTasksMode(val) - } - } - return d -} - -func unquote(v string) string { - t := strings.TrimSpace(v) - if len(t) >= 2 { - first, last := t[0], t[len(t)-1] - if (first == '"' || first == '\'') && first == last { - return t[1 : len(t)-1] - } - } - return t -} - -// --- File tasks (TaskNotes-style: one task per note, metadata in frontmatter) --- - -// taskFileTag is the frontmatter tag that marks a whole note as a task -// (TaskNotes convention, interoperable with TaskForge / Obsidian TaskNotes). -const taskFileTag = "task" - -// doneStatuses are frontmatter `status:` values treated as complete (checked). -var doneStatuses = map[string]bool{ - "done": true, "complete": true, "completed": true, "x": true, -} - -// cancelledStatuses are frontmatter `status:` values treated as cancelled (#450). -var cancelledStatuses = map[string]bool{ - "cancelled": true, "canceled": true, -} - -// inProgressStatuses are frontmatter `status:` values treated as in progress -// (#512). `in-progress` is TaskNotes' spelling; the rest are what people type -// by hand. These stay open work, unlike done/cancelled. -var inProgressStatuses = map[string]bool{ - "in-progress": true, "in progress": true, "inprogress": true, - "doing": true, "started": true, "wip": true, -} - -var ( - taskFmListItemRe = regexp.MustCompile(`^\s*-\s+(.*)$`) - taskFmKvRe = regexp.MustCompile(`^([A-Za-z0-9_][\w-]*)\s*:\s*(.*)$`) - taskFmLeadWsRe = regexp.MustCompile(`^\s`) -) - -// parseTaskFrontmatter parses a leading frontmatter block into flat fields, -// handling scalars, inline arrays (`tags: [a, b]`) and block lists (`tags:` -// then indented ` - a`). Keys are lower-cased; every value is stored as a -// slice (a scalar becomes a single-element slice). Best-effort and never -// panics: just enough YAML for task files, not a full parser. Mirrors -// parseFrontmatterFields in packages/shared-domain/src/frontmatter.ts. -func parseTaskFrontmatter(block string) map[string][]string { - data := map[string][]string{} - listKey := "" - for _, rawLine := range strings.Split(block, "\n") { - trimmed := strings.TrimSpace(rawLine) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - item := taskFmListItemRe.FindStringSubmatch(rawLine) - if listKey != "" && taskFmLeadWsRe.MatchString(rawLine) && item != nil { - data[listKey] = append(data[listKey], unquote(item[1])) - continue - } - kv := taskFmKvRe.FindStringSubmatch(rawLine) - if kv == nil { - listKey = "" - continue - } - key := strings.ToLower(kv[1]) - rest := strings.TrimSpace(kv[2]) - if rest == "" { - // Bare key: a block list may follow on indented `- item` lines. - listKey = key - data[key] = []string{} - continue - } - listKey = "" - if strings.HasPrefix(rest, "[") && strings.HasSuffix(rest, "]") { - var arr []string - for _, s := range strings.Split(rest[1:len(rest)-1], ",") { - v := unquote(s) - if v != "" { - arr = append(arr, v) - } - } - data[key] = arr - } else { - data[key] = []string{unquote(rest)} - } - } - return data -} - -// firstScalar returns the first value of a frontmatter field, or "" when absent. -func firstScalar(v []string) string { - if len(v) == 0 { - return "" - } - return v[0] -} - -// normalizeDueDate unquotes/trims a frontmatter date and returns it only when it -// is a valid YYYY-MM-DD string, otherwise "". Reuses the same validation the -// inline due parser uses. -func normalizeDueDate(raw string) string { - v := unquote(strings.TrimSpace(raw)) - if isValidIsoDate(v) { - return v - } - return "" -} - -// parseTaskFile returns a whole-note "file task" when body has a leading -// frontmatter block whose `tags` include `task`, and ok=false otherwise. All -// metadata comes from frontmatter; the note body is free-form. Mirrors -// parseTaskFile in packages/shared-domain/src/tasks.ts. `body` is expected to -// already be newline-normalized by the caller. -func parseTaskFile(path, title string, folder NoteFolder, body string) (Task, bool) { - m := frontmatterRe.FindStringSubmatch(body) - if len(m) < 2 { - return Task{}, false - } - fm := parseTaskFrontmatter(m[1]) - - tags := []string{} - hasTaskTag := false - for _, t := range fm["tags"] { - tag := strings.ToLower(strings.TrimPrefix(t, "#")) - if tag == taskFileTag { - hasTaskTag = true - continue - } - tags = append(tags, tag) - } - if !hasTaskTag { - return Task{}, false - } - - // "open" is the effective state of a note that says nothing, not a custom - // status the author chose: only an explicit status: reaches Fields, so the - // note sits in the board's "No status" column and a drop there (which - // clears the key) survives the next rescan. Mirrors parseTaskFile in - // packages/shared-domain/src/tasks.ts (#672). - status := "open" - fields := map[string]string{} - if s := firstScalar(fm["status"]); s != "" { - status = strings.ToLower(s) - fields["status"] = status - } - content := title - if t := strings.TrimSpace(firstScalar(fm["title"])); t != "" { - content = t - } - - return Task{ - ID: path + "#task", - SourcePath: path, - NoteTitle: title, - NoteFolder: folder, - LineNumber: 0, - TaskIndex: -1, - RawText: "", - Content: content, - Checked: doneStatuses[status], - Cancelled: cancelledStatuses[status], - InProgress: inProgressStatuses[status], - Due: normalizeDueDate(firstScalar(fm["due"])), - Priority: normalizePriority(firstScalar(fm["priority"])), - Waiting: status == "waiting", - Fields: fields, - Status: status, - Tags: tags, - Kind: "file", - Scheduled: normalizeDueDate(firstScalar(fm["scheduled"])), - CompletedDate: normalizeDueDate(firstScalar(fm["completeddate"])), - }, true -} - -// ParseTasksOptions controls scanning past task exclusions (#458). -type ParseTasksOptions struct { - // IncludeExcluded scans past the note-level frontmatter `tasks:` opt-out: - // the GET /tasks?includeExcluded=1 escape hatch. Default listing never sets - // it. - IncludeExcluded bool -} - -// ParseTasks walks a markdown body and returns every checkbox task. -func ParseTasks(path, title string, folder NoteFolder, body string) []Task { - return ParseTasksWith(path, title, folder, body, ParseTasksOptions{}) -} - -// ParseTasksWith is ParseTasks honoring options. The frontmatter `tasks:` -// gate lives here rather than in the parse helpers (the TS mirrors gate -// inside parseTaskFile/parseTasksFromBody because their callers invoke the -// two separately); the semantics are identical: `tasks: false`/`off` emits -// nothing and wins over `tags: [task]`, `tasks: note` keeps only the file -// task, anything else emits everything. -func ParseTasksWith(path, title string, folder NoteFolder, body string, opts ParseTasksOptions) []Task { - normalized := strings.ReplaceAll(body, "\r\n", "\n") - defaults := parseNoteDefaults(normalized) - lines := strings.Split(normalized, "\n") - - mode := defaults.TasksMode - if opts.IncludeExcluded { - mode = tasksModeAll - } - - out := []Task{} - // A whole-note "file task" (if the frontmatter is tagged `task`) is emitted - // before the inline checkbox tasks in the same note, which act as subtasks. - if mode != tasksModeNone { - if fileTask, ok := parseTaskFile(path, title, folder, normalized); ok { - out = append(out, fileTask) - } - } - if mode != tasksModeAll { - return out - } - taskIndex := 0 - inFence := false - fenceMarker := "" - - fenceStart := regexp.MustCompile("^([ \t]*)(`{3,}|~{3,})") - - for i, line := range lines { - if fm := fenceStart.FindStringSubmatch(line); fm != nil { - marker := fm[2] - if !inFence { - inFence = true - fenceMarker = marker - } else if marker == fenceMarker { - inFence = false - fenceMarker = "" - } - continue - } - if inFence { - continue - } - m := taskLineRe.FindStringSubmatch(line) - if m == nil { - continue - } - checkedChar := m[2] - tail := strings.TrimPrefix(m[3], "]") - checked := checkedChar == "x" || checkedChar == "X" - cancelled := checkedChar == "-" - inProgress := checkedChar == "/" - forwarded := checkedChar == ">" - - due := "" - priority := "" - waiting := false - fields := map[string]string{} - tags := []string{} - stripped := tail - - if dm := inlineDueRe.FindStringSubmatch(stripped); dm != nil { - if isValidIsoDate(dm[1]) { - due = dm[1] - } - stripped = inlineDueRe.ReplaceAllString(stripped, " ") - } - if pm := inlinePriority.FindStringSubmatch(stripped); pm != nil { - priority = normalizePriority(pm[1]) - stripped = inlinePriority.ReplaceAllString(stripped, " ") - } - if inlineWaitingRe.MatchString(stripped) { - waiting = true - stripped = inlineWaitingRe.ReplaceAllString(stripped, " ") - } - for _, fm := range inlineFieldRe.FindAllStringSubmatch(stripped, -1) { - if len(fm) < 3 { - continue - } - key := strings.ToLower(fm[1]) - if _, exists := fields[key]; !exists { - fields[key] = strings.ToLower(fm[2]) - } - } - if len(fields) > 0 { - stripped = inlineFieldRe.ReplaceAllString(stripped, " ") - } - for _, tm := range inlineTagRe.FindAllStringSubmatch(tail, -1) { - if len(tm) >= 2 { - tag := strings.ToLower(tm[1]) - dupe := false - for _, t := range tags { - if t == tag { - dupe = true - break - } - } - if !dupe { - tags = append(tags, tag) - } - } - } - stripped = strings.TrimSpace(wsCollapseRe.ReplaceAllString(stripped, " ")) - content := stripped - if content == "" { - content = strings.TrimSpace(tail) - } - - if due == "" { - due = defaults.Due - } - if priority == "" { - priority = defaults.Priority - } - if _, hasStatus := fields["status"]; !hasStatus && defaults.Status != "" { - fields["status"] = defaults.Status - } - - task := Task{ - ID: fmtTaskID(path, taskIndex), - SourcePath: path, - NoteTitle: title, - NoteFolder: folder, - LineNumber: i, - TaskIndex: taskIndex, - RawText: line, - Content: content, - Checked: checked, - Cancelled: cancelled, - InProgress: inProgress, - Forwarded: forwarded, - Due: due, - Priority: priority, - Waiting: waiting, - Fields: fields, - Status: fields["status"], - Tags: tags, - } - out = append(out, task) - taskIndex++ - } - return out -} - -func fmtTaskID(path string, idx int) string { - return path + "#" + itoa(idx) -} - -func itoa(n int) string { - if n == 0 { - return "0" - } - neg := false - if n < 0 { - neg = true - n = -n - } - var buf [20]byte - i := len(buf) - for n > 0 { - i-- - buf[i] = byte('0' + n%10) - n /= 10 - } - if neg { - i-- - buf[i] = '-' - } - return string(buf[i:]) -} diff --git a/apps/server/internal/vault/parse_test.go b/apps/server/internal/vault/parse_test.go deleted file mode 100644 index 50ac204b..00000000 --- a/apps/server/internal/vault/parse_test.go +++ /dev/null @@ -1,317 +0,0 @@ -package vault - -import "testing" - -func TestBodyHasLocalAssetDetectsOnlyLocalAssets(t *testing.T) { - cases := []struct { - name string - body string - want bool - }{ - { - name: "plain wikilink", - body: "# Plain\n\n[[Project Note]]\n", - want: false, - }, - { - name: "relative image", - body: "# Image\n\n![diagram](../attachements/diagram.png)\n", - want: true, - }, - { - name: "embedded pdf", - body: "# Embed\n\n![[brief.pdf]]\n", - want: true, - }, - { - name: "remote image", - body: "# Remote\n\n![diagram](https://example.com/diagram.png)\n", - want: false, - }, - { - name: "code fenced local asset", - body: "# Code\n\n```md\n![diagram](local.png)\n```\n", - want: false, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := BodyHasLocalAsset(tc.body); got != tc.want { - t.Fatalf("BodyHasLocalAsset() = %v, want %v", got, tc.want) - } - }) - } -} - -func TestExtractorsStillIgnoreCodeAfterFastPathGuards(t *testing.T) { - body := "# Real #tag\n\n```md\n#ignored [[Ignored]] ![[ignored.pdf]]\n```\n\n[[Target|Label]]" - - tags := ExtractTags(body) - if len(tags) != 1 || tags[0] != "tag" { - t.Fatalf("ExtractTags() = %#v, want [tag]", tags) - } - - wikilinks := ExtractWikilinks(body) - if len(wikilinks) != 1 || wikilinks[0] != "Target" { - t.Fatalf("ExtractWikilinks() = %#v, want [Target]", wikilinks) - } -} - -// #293: a fenced code block nested under a list item (indented) is still code — -// its `#include` line must not be indexed as a tag. -func TestExtractTagsIgnoresIndentedFence(t *testing.T) { - body := "- bullet\n\n ```c\n #include \n ```\n\n#kept" - - tags := ExtractTags(body) - if len(tags) != 1 || tags[0] != "kept" { - t.Fatalf("ExtractTags() = %#v, want [kept]", tags) - } -} - -func TestExtractTagsIncludesFrontmatterTags(t *testing.T) { - body := "---\ntags: [frontmatter, \"#quoted\", project/nested]\ntitle: #ignored\n---\n\n#inline" - - tags := ExtractTags(body) - want := []string{"frontmatter", "quoted", "project/nested", "inline"} - if len(tags) != len(want) { - t.Fatalf("ExtractTags() = %#v, want %#v", tags, want) - } - for i := range want { - if tags[i] != want[i] { - t.Fatalf("ExtractTags() = %#v, want %#v", tags, want) - } - } -} - -func TestExtractTagsSplitsBareFrontmatterScalar(t *testing.T) { - tags := ExtractTags("---\ntags: daily, work\n---\nbody") - if len(tags) != 2 || tags[0] != "daily" || tags[1] != "work" { - t.Fatalf("ExtractTags() = %#v, want [daily work]", tags) - } -} - -func TestExtractTagsIncludesFrontmatterTagList(t *testing.T) { - body := "---\ntags:\n - daily\n - \"#log\"\n---\n\nBody" - - tags := ExtractTags(body) - if len(tags) != 2 || tags[0] != "daily" || tags[1] != "log" { - t.Fatalf("ExtractTags() = %#v, want [daily log]", tags) - } -} - -// #205: tags in non-Latin scripts (Cyrillic, CJK, …) must be recognized. -func TestExtractTagsUnicode(t *testing.T) { - body := "Заметки: #тест #ошибка/баг и 笔记 #标签 plus #ascii-1 done" - got := ExtractTags(body) - want := map[string]bool{"тест": true, "ошибка/баг": true, "标签": true, "ascii-1": true} - if len(got) != len(want) { - t.Fatalf("ExtractTags() = %#v, want keys %#v", got, want) - } - for _, tag := range got { - if !want[tag] { - t.Fatalf("unexpected tag %q in %#v", tag, got) - } - } -} - -// #450: `[-]` cancelled tasks must be parsed (not dropped) and flagged cancelled. -func TestParseTasksRecognizesCancelled(t *testing.T) { - body := "- [ ] open\n- [x] done\n- [>] gone\n- [-] scrapped\n" - tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body) - if len(tasks) != 4 { - t.Fatalf("expected 4 tasks (none dropped), got %d", len(tasks)) - } - byContent := map[string]Task{} - for _, tk := range tasks { - byContent[tk.Content] = tk - } - if c, ok := byContent["scrapped"]; !ok { - t.Fatal("cancelled task line was dropped") - } else if !c.Cancelled || c.Checked { - t.Errorf("scrapped: Cancelled=%v Checked=%v, want Cancelled=true Checked=false", c.Cancelled, c.Checked) - } - if byContent["open"].Cancelled || byContent["done"].Cancelled { - t.Error("open/done tasks should not be cancelled") - } -} - -func TestParseTaskFileCancelledStatus(t *testing.T) { - body := "---\ntags: [task]\ntitle: Rewrite\nstatus: cancelled\n---\n\nAbandoned.\n" - task, ok := parseTaskFile("inbox/x.md", "x", FolderInbox, body) - if !ok { - t.Fatal("expected a file task") - } - if !task.Cancelled || task.Checked { - t.Errorf("Cancelled=%v Checked=%v, want Cancelled=true Checked=false", task.Cancelled, task.Checked) - } -} - -// #512: `[/]` in-progress tasks parse as open work, flagged InProgress. The -// server mirrors shared-domain here, so a web client sees the same states the -// desktop app does. -func TestParseTasksRecognizesInProgress(t *testing.T) { - body := "- [ ] open\n- [/] started\n- [x] done\n- [-] scrapped\n1. [/] numbered\n" - tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body) - if len(tasks) != 5 { - t.Fatalf("expected 5 tasks (none dropped), got %d", len(tasks)) - } - byContent := map[string]Task{} - for _, tk := range tasks { - byContent[tk.Content] = tk - } - for _, name := range []string{"started", "numbered"} { - tk, ok := byContent[name] - if !ok { - t.Fatalf("in-progress task %q was dropped", name) - } - if !tk.InProgress { - t.Errorf("%s: InProgress=false, want true", name) - } - if tk.Checked || tk.Cancelled { - t.Errorf("%s: Checked=%v Cancelled=%v, want both false", name, tk.Checked, tk.Cancelled) - } - } - if byContent["open"].InProgress || byContent["done"].InProgress || byContent["scrapped"].InProgress { - t.Error("open/done/cancelled tasks should not be in progress") - } -} - -func TestParseTaskFileInProgressStatus(t *testing.T) { - for _, status := range []string{"in-progress", "doing", "started", "wip"} { - body := "---\ntags: [task]\ntitle: Rewrite\nstatus: " + status + "\n---\n\nHalf done.\n" - task, ok := parseTaskFile("inbox/x.md", "x", FolderInbox, body) - if !ok { - t.Fatalf("%s: expected a file task", status) - } - if !task.InProgress { - t.Errorf("%s: InProgress=false, want true", status) - } - if task.Checked || task.Cancelled { - t.Errorf("%s: Checked=%v Cancelled=%v, want both false", status, task.Checked, task.Cancelled) - } - } -} - -// #643: a server-backed board must receive the same custom-status fields as -// the desktop parser. Otherwise the optimistic move sticks until the watcher -// rescan replaces it with a task that appears to have no status. -func TestParseTaskFileIncludesCustomStatusField(t *testing.T) { - body := "---\ntags: [task]\ntitle: Rewrite\nstatus: A\n---\n\nDetails.\n" - task, ok := parseTaskFile("inbox/x.md", "x", FolderInbox, body) - if !ok { - t.Fatal("expected a file task") - } - if task.Status != "a" { - t.Errorf("Status=%q, want %q", task.Status, "a") - } - if got := task.Fields["status"]; got != "a" { - t.Errorf("Fields[status]=%q, want %q", got, "a") - } -} - -// #672: a file task whose frontmatter says nothing is effectively open but -// has no custom status. Reporting one put it in an "Open" column with a -// phantom @status:open chip, and a drop into "No status" (which clears the -// key) was undone by the next rescan, so the card bounced between the two. -func TestParseTaskFileWithoutStatusHasNoCustomStatusField(t *testing.T) { - body := "---\ntitle: Ship it\ntags: [ task ]\ndue: 2026-08-24\n---\n" - task, ok := parseTaskFile("inbox/x.md", "x", FolderInbox, body) - if !ok { - t.Fatal("expected a file task") - } - if task.Status != "open" || task.Checked || task.Cancelled { - t.Errorf("Status=%q Checked=%v Cancelled=%v, want open/false/false", task.Status, task.Checked, task.Cancelled) - } - if _, has := task.Fields["status"]; has { - t.Errorf("Fields=%#v, want no status key", task.Fields) - } - if task.Fields == nil { - t.Error("Fields must be an empty map, not nil, so the JSON stays {}") - } -} - -func TestParseTasksIncludesCustomFields(t *testing.T) { - body := "---\nstatus: Backlog\n---\n- [ ] inherits\n- [ ] override @status:Review @sprint:24 @area:Backend\n" - tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body) - if len(tasks) != 2 { - t.Fatalf("expected 2 tasks, got %d", len(tasks)) - } - if tasks[0].Status != "backlog" || tasks[0].Fields["status"] != "backlog" { - t.Errorf("inherited task fields=%#v status=%q, want status=backlog", tasks[0].Fields, tasks[0].Status) - } - want := map[string]string{"status": "review", "sprint": "24", "area": "backend"} - for key, value := range want { - if got := tasks[1].Fields[key]; got != value { - t.Errorf("Fields[%s]=%q, want %q", key, got, value) - } - } - if tasks[1].Status != "review" { - t.Errorf("Status=%q, want review", tasks[1].Status) - } - if tasks[1].Content != "override" { - t.Errorf("Content=%q, want custom-field tokens stripped", tasks[1].Content) - } -} - -// #458: the frontmatter `tasks:` key turns a note's checkboxes back into plain -// checkboxes. The server mirrors noteTasksMode in shared-domain; the accepted -// values must stay byte-identical across runtimes. -func TestParseTasksFrontmatterTasksOptOut(t *testing.T) { - checklist := "- [ ] Dune\n- [x] Hyperion\n- [ ] Blindsight due:2026-09-01\n" - for _, val := range []string{"false", "off", "False", "OFF", "\"false\""} { - body := "---\ntasks: " + val + "\n---\n" + checklist - if tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body); len(tasks) != 0 { - t.Errorf("tasks: %s: expected no tasks, got %d", val, len(tasks)) - } - } - // Unrecognized values fall back to the pre-#458 behavior. - for _, val := range []string{"true", "yes", "everything"} { - body := "---\ntasks: " + val + "\n---\n" + checklist - if tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body); len(tasks) != 3 { - t.Errorf("tasks: %s: expected 3 tasks, got %d", val, len(tasks)) - } - } -} - -func TestParseTasksFrontmatterTasksFalseWinsOverTaskTag(t *testing.T) { - body := "---\ntags: [task]\ntasks: false\n---\n\n- [ ] hidden\n" - if tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body); len(tasks) != 0 { - t.Fatalf("expected tasks: false to suppress the file task too, got %d tasks", len(tasks)) - } -} - -func TestParseTasksFrontmatterTasksNoteKeepsFileTaskOnly(t *testing.T) { - body := "---\ntags: [task]\ntasks: note\nstatus: in-progress\ndue: 2026-09-01\n---\n\n- [ ] research\n- [x] outline\n" - tasks := ParseTasks("inbox/t.md", "t", FolderInbox, body) - if len(tasks) != 1 { - t.Fatalf("expected exactly the file task, got %d tasks", len(tasks)) - } - tk := tasks[0] - if tk.Kind != "file" || tk.ID != "inbox/t.md#task" { - t.Errorf("Kind=%q ID=%q, want file task", tk.Kind, tk.ID) - } - if !tk.InProgress || tk.Due != "2026-09-01" { - t.Errorf("InProgress=%v Due=%q, want frontmatter metadata intact", tk.InProgress, tk.Due) - } - // On a note without the task tag, `tasks: note` simply silences checkboxes. - plain := "---\ntasks: note\n---\n\n- [ ] a\n- [ ] b\n" - if tasks := ParseTasks("inbox/p.md", "p", FolderInbox, plain); len(tasks) != 0 { - t.Errorf("expected no tasks for tasks: note without a task tag, got %d", len(tasks)) - } -} - -func TestParseTasksWithIncludeExcluded(t *testing.T) { - body := "---\ntags: [task]\ntasks: false\n---\n\n- [ ] hidden\n- [ ] also hidden\n" - tasks := ParseTasksWith("inbox/t.md", "t", FolderInbox, body, ParseTasksOptions{IncludeExcluded: true}) - if len(tasks) != 3 { - t.Fatalf("expected file task + 2 inline with IncludeExcluded, got %d", len(tasks)) - } - if tasks[0].ID != "inbox/t.md#task" { - t.Errorf("first task ID=%q, want the file task", tasks[0].ID) - } - // Index counting is untouched by the gate, so ids stay stable. - if tasks[1].ID != "inbox/t.md#0" || tasks[2].ID != "inbox/t.md#1" { - t.Errorf("inline ids %q, %q, want #0 and #1", tasks[1].ID, tasks[2].ID) - } -} diff --git a/apps/server/internal/vault/safepath.go b/apps/server/internal/vault/safepath.go deleted file mode 100644 index 44698ac9..00000000 --- a/apps/server/internal/vault/safepath.go +++ /dev/null @@ -1,92 +0,0 @@ -package vault - -import ( - "errors" - "os" - "path/filepath" - "strings" -) - -var ErrPathEscape = errors.New("path escapes vault root") - -// ErrIsDirectory is returned when a caller asks to read a directory as a file, -// which a client does by accident whenever it treats a `.base` database folder -// as a note. It is classified here, from a stat the read already performs, -// rather than from the errno the read returns: Unix answers EISDIR but Windows -// answers ERROR_INVALID_FUNCTION ("Incorrect function"), so an errno test makes -// the same request a 400 on one platform and a 500 on another. -var ErrIsDirectory = errors.New("path is a directory, not a file") - -// SafeJoin cleans a user-supplied relative POSIX path and joins it onto -// `root`, refusing anything that resolves outside `root`. Any existing -// component of the joined path that is a symbolic link is resolved and -// must still resolve to a location inside `root`; otherwise ErrPathEscape -// is returned. Components that do not yet exist (write-create case) are -// left alone — they cannot be symlinks until they're created. -// -// The returned path is in the same namespace as `root` (i.e. rooted at -// the abs form of the caller's root, not the canonical form), so callers -// can still strip the root prefix to get a stable relative path. -func SafeJoin(root, rel string) (string, error) { - if root == "" { - return "", errors.New("root is empty") - } - rootAbs, err := filepath.Abs(root) - if err != nil { - return "", err - } - cleaned := filepath.Clean("/" + strings.TrimPrefix(rel, "/")) - joined := filepath.Join(rootAbs, filepath.FromSlash(cleaned)) - - relBack, err := filepath.Rel(rootAbs, joined) - if err != nil { - return "", err - } - if relBack == ".." || strings.HasPrefix(relBack, ".."+string(filepath.Separator)) { - return "", ErrPathEscape - } - - rootCanonical, err := filepath.EvalSymlinks(rootAbs) - if err != nil { - // Root not on disk yet (e.g. caller is about to MkdirAll). Without - // a canonical root we can't meaningfully evaluate symlink targets, - // so fall back to the lexical-only result. - return joined, nil - } - if relBack == "." { - return joined, nil - } - - parts := strings.Split(relBack, string(filepath.Separator)) - walk := rootAbs - for _, part := range parts { - walk = filepath.Join(walk, part) - info, err := os.Lstat(walk) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return joined, nil - } - return "", err - } - if info.Mode()&os.ModeSymlink != 0 { - target, err := filepath.EvalSymlinks(walk) - if err != nil { - return "", err - } - relTarget, err := filepath.Rel(rootCanonical, target) - if err != nil { - return "", err - } - if relTarget == ".." || strings.HasPrefix(relTarget, ".."+string(filepath.Separator)) { - return "", ErrPathEscape - } - walk = target - } - } - return joined, nil -} - -// ToPosix converts an OS-native path to forward-slash form. -func ToPosix(p string) string { - return filepath.ToSlash(p) -} diff --git a/apps/server/internal/vault/safepath_test.go b/apps/server/internal/vault/safepath_test.go deleted file mode 100644 index 2c132e9d..00000000 --- a/apps/server/internal/vault/safepath_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package vault - -import ( - "errors" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestSafeJoinLexical(t *testing.T) { - root := t.TempDir() - - cases := []struct { - name string - rel string - wantErr error - }{ - {"plain file", "note.md", nil}, - {"nested", "a/b/c.md", nil}, - {"leading slash", "/note.md", nil}, - {"dot only", ".", nil}, - // Leading ".." is neutralised by the leading "/" anchor before - // Clean(), so these resolve safely inside root rather than - // escaping. The escape path is exercised via symlinks in the - // dedicated tests below. - {"parent neutralised", "../escape.md", nil}, - {"deep parent neutralised", "a/../../escape.md", nil}, - {"absolute neutralised", "/../../etc/passwd", nil}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - abs, err := SafeJoin(root, tc.rel) - if tc.wantErr != nil { - if !errors.Is(err, tc.wantErr) { - t.Fatalf("expected %v, got err=%v abs=%q", tc.wantErr, err, abs) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - rootAbs, _ := filepath.Abs(root) - if !strings.HasPrefix(abs, rootAbs) { - t.Fatalf("result %q is not under root %q", abs, rootAbs) - } - }) - } -} - -func TestSafeJoinSymlinkEscape(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - outside := t.TempDir() - target := filepath.Join(outside, "secret.txt") - if err := os.WriteFile(target, []byte("hush"), 0o600); err != nil { - t.Fatal(err) - } - link := filepath.Join(root, "evil.md") - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - - if _, err := SafeJoin(root, "evil.md"); !errors.Is(err, ErrPathEscape) { - t.Fatalf("expected ErrPathEscape, got %v", err) - } -} - -func TestSafeJoinSymlinkInsideVault(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - target := filepath.Join(root, "real.md") - if err := os.WriteFile(target, []byte("hi"), 0o600); err != nil { - t.Fatal(err) - } - link := filepath.Join(root, "alias.md") - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - - abs, err := SafeJoin(root, "alias.md") - if err != nil { - t.Fatalf("expected success, got %v", err) - } - if abs == "" { - t.Fatal("empty result") - } -} - -func TestSafeJoinSymlinkInPathSegment(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - outside := t.TempDir() - - // /root/sneaky -> /outside (symlinked directory) - if err := os.Symlink(outside, filepath.Join(root, "sneaky")); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(outside, "leak.md"), []byte("x"), 0o600); err != nil { - t.Fatal(err) - } - - if _, err := SafeJoin(root, "sneaky/leak.md"); !errors.Is(err, ErrPathEscape) { - t.Fatalf("expected ErrPathEscape on symlinked subdir, got %v", err) - } -} - -func TestSafeJoinNonexistentTail(t *testing.T) { - root := t.TempDir() - abs, err := SafeJoin(root, "newfolder/new.md") - if err != nil { - t.Fatalf("expected success for non-existent path under root, got %v", err) - } - rootAbs, _ := filepath.Abs(root) - if !strings.HasPrefix(abs, rootAbs) { - t.Fatalf("result %q is not under root %q", abs, rootAbs) - } -} - -func TestSafeJoinRootMissing(t *testing.T) { - root := filepath.Join(t.TempDir(), "does-not-exist-yet") - abs, err := SafeJoin(root, "note.md") - if err != nil { - t.Fatalf("expected lexical fallback when root absent, got %v", err) - } - if !strings.HasSuffix(abs, "note.md") { - t.Fatalf("unexpected path %q", abs) - } -} diff --git a/apps/server/internal/vault/system_folder_paths_test.go b/apps/server/internal/vault/system_folder_paths_test.go deleted file mode 100644 index f150dc74..00000000 --- a/apps/server/internal/vault/system_folder_paths_test.go +++ /dev/null @@ -1,395 +0,0 @@ -package vault - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -// remapVault returns a vault whose system folders live at custom paths. -func remapVault(t *testing.T, paths map[string]string) *Vault { - t.Helper() - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - SystemFolderPaths: paths, - }); err != nil { - t.Fatal(err) - } - settings, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - for folder, want := range paths { - if got := settings.SystemFolderPaths[folder]; got != want { - t.Fatalf("settings.systemFolderPaths[%s] = %q, want %q", folder, got, want) - } - } - return v -} - -func TestRemappedInboxIsReadAndWrittenInTheCustomDirectory(t *testing.T) { - v := remapVault(t, map[string]string{"inbox": "bucket"}) - - if _, err := os.Stat(filepath.Join(v.Root(), "bucket")); err != nil { - t.Fatalf("remapped inbox directory was not created: %v", err) - } - - meta, err := v.CreateNote(FolderInbox, "Remapped", "") - if err != nil { - t.Fatal(err) - } - if meta.Path != "bucket/Remapped.md" { - t.Fatalf("created note path = %q, want bucket/Remapped.md", meta.Path) - } - if _, err := os.Stat(filepath.Join(v.Root(), "bucket", "Remapped.md")); err != nil { - t.Fatalf("note did not land in the remapped inbox: %v", err) - } - if _, err := os.Stat(filepath.Join(v.Root(), "inbox", "Remapped.md")); err == nil { - t.Fatal("note was written to the default inbox directory") - } - - read, err := v.ReadNote("bucket/Remapped.md") - if err != nil { - t.Fatal(err) - } - if read.Folder != FolderInbox { - t.Fatalf("read note folder = %q, want inbox", read.Folder) - } - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - found := false - for _, note := range notes { - if note.Path == "bucket/Remapped.md" { - found = true - if note.Folder != FolderInbox { - t.Fatalf("listed note folder = %q, want inbox", note.Folder) - } - } - } - if !found { - t.Fatalf("remapped inbox note missing from ListNotes: %+v", notes) - } -} - -func TestRemappedTrashRoundTripKeepsTheSubfolder(t *testing.T) { - v := remapVault(t, map[string]string{"inbox": "bucket", "trash": "deleted"}) - - created, err := v.CreateNote(FolderInbox, "Doomed", "Projects") - if err != nil { - t.Fatal(err) - } - if created.Path != "bucket/Projects/Doomed.md" { - t.Fatalf("created note path = %q, want bucket/Projects/Doomed.md", created.Path) - } - - trashed, err := v.MoveToTrash(created.Path) - if err != nil { - t.Fatal(err) - } - if trashed.Path != "deleted/Projects/Doomed.md" || trashed.Folder != FolderTrash { - t.Fatalf("trashed note = {%q %q}, want {deleted/Projects/Doomed.md trash}", trashed.Path, trashed.Folder) - } - - restored, err := v.RestoreFromTrash(trashed.Path) - if err != nil { - t.Fatal(err) - } - if restored.Path != "bucket/Projects/Doomed.md" || restored.Folder != FolderInbox { - t.Fatalf("restored note = {%q %q}, want {bucket/Projects/Doomed.md inbox}", restored.Path, restored.Folder) - } -} - -func TestPrimaryRootListsADefaultNameThatWasRemappedAway(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesRoot, - SystemFolderPaths: map[string]string{"quick": "Fast"}, - }); err != nil { - t.Fatal(err) - } - if err := v.EnsureLayout(); err != nil { - t.Fatal(err) - } - - // `quick/` is an ordinary user folder now that quick lives in `Fast/`, so - // the root walk must stop skipping it. - write := func(rel string) { - t.Helper() - abs := filepath.Join(v.Root(), filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(abs, []byte("body"), 0o600); err != nil { - t.Fatal(err) - } - } - write("quick/User.md") - write("Fast/Scratch.md") - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - got := map[string]NoteFolder{} - for _, note := range notes { - got[note.Path] = note.Folder - } - if folder, ok := got["quick/User.md"]; !ok || folder != FolderInbox { - t.Fatalf("quick/User.md = (%q, %v), want (inbox, true); listing: %v", folder, ok, got) - } - if folder, ok := got["Fast/Scratch.md"]; !ok || folder != FolderQuick { - t.Fatalf("Fast/Scratch.md = (%q, %v), want (quick, true); listing: %v", folder, ok, got) - } -} - -func TestSettingsRoundTripKeepsSystemFolderPaths(t *testing.T) { - v := remapVault(t, map[string]string{"archive": "cold-storage"}) - - written, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - SystemFolderPaths: map[string]string{"archive": "cold-storage"}, - }) - if err != nil { - t.Fatal(err) - } - // SetSettings' return value is what the HTTP layer echoes to the client. - if got := written.SystemFolderPaths["archive"]; got != "cold-storage" { - t.Fatalf("SetSettings returned systemFolderPaths[archive] = %q, want cold-storage", got) - } -} - -func TestGetSettingsCachesUntilTheFileChanges(t *testing.T) { - v := remapVault(t, map[string]string{"inbox": "bucket"}) - - // White-box: poison the cached copy. A second read that still reports the - // poisoned value proves the file was not re-read and re-parsed. - v.settingsMu.Lock() - if v.settingsCache == nil { - v.settingsMu.Unlock() - t.Fatal("settings were not cached after a read") - } - v.settingsCache.settings.SystemFolderPaths["inbox"] = "sentinel" - v.settingsMu.Unlock() - - cached, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if cached.SystemFolderPaths["inbox"] != "sentinel" { - t.Fatal("GetSettings re-parsed vault.json even though it had not changed") - } - - // The caller gets a copy, so mutating it cannot corrupt the cache. - cached.SystemFolderPaths["inbox"] = "mutated" - again, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if again.SystemFolderPaths["inbox"] != "sentinel" { - t.Fatal("mutating the returned settings leaked into the cache") - } - - // A vault.json written behind the vault's back is picked up: the mtime and - // size no longer match the cached key. - raw, err := json.Marshal(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - SystemFolderPaths: map[string]string{"inbox": "elsewhere", "trash": "deleted"}, - }) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(v.settingsPath(), raw, 0o600); err != nil { - t.Fatal(err) - } - fresh, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if fresh.SystemFolderPaths["inbox"] != "elsewhere" || fresh.SystemFolderPaths["trash"] != "deleted" { - t.Fatalf("edited vault.json was not picked up: %+v", fresh.SystemFolderPaths) - } -} - -func TestFolderForRelativePathPrefersOverridesOverDefaultNames(t *testing.T) { - cases := []struct { - name string - rel string - paths map[string]string - want NoteFolder - ok bool - }{ - { - name: "default names classify with no overrides", - rel: "archive/Note.md", - want: FolderArchive, - ok: true, - }, - { - name: "remapped folder classifies by its custom path", - rel: "deleted/Note.md", - paths: map[string]string{"trash": "deleted"}, - want: FolderTrash, - ok: true, - }, - { - name: "a default name left behind is a user folder", - rel: "trash/Note.md", - paths: map[string]string{"trash": "deleted"}, - want: FolderInbox, - ok: true, - }, - { - name: "a remapped-away inbox name is a user folder", - rel: "inbox/Note.md", - paths: map[string]string{"inbox": "bucket"}, - want: FolderInbox, - ok: true, - }, - { - name: "a swap classifies by location, not by name", - rel: "archive/Note.md", - paths: map[string]string{"inbox": "archive", "archive": "bucket"}, - want: FolderInbox, - ok: true, - }, - { - name: "the swapped-out folder classifies too", - rel: "bucket/Note.md", - paths: map[string]string{"inbox": "archive", "archive": "bucket"}, - want: FolderArchive, - ok: true, - }, - { - // The on-disk case is whatever the directory was created with, so - // classification is case-insensitive like the TS side. (#186) - name: "a default name classifies whatever its case", - rel: "Archive/Note.md", - want: FolderArchive, - ok: true, - }, - { - name: "a custom path classifies whatever its case", - rel: "DELETED/Note.md", - paths: map[string]string{"trash": "deleted"}, - want: FolderTrash, - ok: true, - }, - { - name: "reserved root names stay unclassified", - rel: "assets/image.png", - ok: false, - }, - { - name: "reserved root names stay unclassified with overrides too", - rel: "attachements/image.png", - paths: map[string]string{"inbox": "bucket"}, - ok: false, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - folder, ok := FolderForRelativePathWithSettings(tc.rel, tc.paths) - if ok != tc.ok || (tc.ok && folder != tc.want) { - t.Fatalf("FolderForRelativePathWithSettings(%q, %v) = (%q, %v), want (%q, %v)", - tc.rel, tc.paths, folder, ok, tc.want, tc.ok) - } - }) - } -} - -func TestNormalizeSystemFolderPathsRejectsCollisions(t *testing.T) { - cases := []struct { - name string - raw map[string]string - want map[string]string - }{ - { - name: "a swap collapses to no overrides at all", - raw: map[string]string{"inbox": "archive", "archive": "inbox"}, - want: nil, - }, - { - name: "taking another folder's default name is rejected", - raw: map[string]string{"inbox": "trash"}, - want: nil, - }, - { - name: "taking another folder's default name is rejected case-insensitively", - raw: map[string]string{"inbox": "Trash"}, - want: nil, - }, - { - name: "the rejection is per entry", - raw: map[string]string{"inbox": "archive", "trash": "deleted"}, - want: map[string]string{"trash": "deleted"}, - }, - { - // Which of the two survives is the sweep order (inbox, quick, - // archive, trash), the same order the TS normalizer uses. - name: "two folders may not share one custom path", - raw: map[string]string{"quick": "shared", "trash": "shared"}, - want: map[string]string{"trash": "shared"}, - }, - { - name: "a three-way rotation collapses too", - raw: map[string]string{"inbox": "quick", "quick": "archive", "archive": "inbox"}, - want: nil, - }, - { - name: "a valid override survives its rejected swap partners", - raw: map[string]string{"inbox": "archive", "archive": "inbox", "trash": "deleted"}, - want: map[string]string{"trash": "deleted"}, - }, - { - // Only the OTHER folders' defaults are off limits; recasing your own - // is a real rename on a case-preserving filesystem. - name: "a folder may recase its own default name", - raw: map[string]string{"archive": "Archive"}, - want: map[string]string{"archive": "Archive"}, - }, - { - name: "reserved names are rejected", - raw: map[string]string{"trash": "assets"}, - want: nil, - }, - { - name: "reserved names are rejected case-insensitively", - raw: map[string]string{"trash": "Comments"}, - want: nil, - }, - { - name: "a folder's own default name is dropped as a no-op", - raw: map[string]string{"trash": "trash"}, - want: nil, - }, - { - name: "independent custom paths survive", - raw: map[string]string{"inbox": "bucket", "trash": "deleted"}, - want: map[string]string{"inbox": "bucket", "trash": "deleted"}, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := NormalizeSystemFolderPaths(tc.raw) - if len(got) != len(tc.want) { - t.Fatalf("NormalizeSystemFolderPaths(%v) = %v, want %v", tc.raw, got, tc.want) - } - for key, want := range tc.want { - if got[key] != want { - t.Fatalf("NormalizeSystemFolderPaths(%v) = %v, want %v", tc.raw, got, tc.want) - } - } - }) - } -} diff --git a/apps/server/internal/vault/tasks_exclude.go b/apps/server/internal/vault/tasks_exclude.go deleted file mode 100644 index 5fa8e675..00000000 --- a/apps/server/internal/vault/tasks_exclude.go +++ /dev/null @@ -1,87 +0,0 @@ -package vault - -import "strings" - -// The vault-level "exclude this folder from Tasks" list (#458). Byte-for-byte -// mirror of packages/shared-domain/src/tasks-excluded-folders.ts: change both -// together. Entries are vault-relative directory paths exactly as they exist -// on disk, so remapped system folders (#115) need no translation. - -// normalizeTasksExcludedFolder validates one entry: forward slashes, no empty -// or dot segments, no traversal. Returns "" when invalid. -func normalizeTasksExcludedFolder(value string) string { - parts := []string{} - for _, seg := range strings.Split(strings.ReplaceAll(value, "\\", "/"), "/") { - s := strings.TrimSpace(seg) - if s == "" { - continue - } - if s == "." || s == ".." { - return "" - } - parts = append(parts, s) - } - if len(parts) == 0 { - return "" - } - joined := strings.Join(parts, "/") - if len(joined) > 512 { - return "" - } - return joined -} - -// normalizeTasksExcludedFolders drops invalid entries and duplicates, -// preserving order. -func normalizeTasksExcludedFolders(values []string) []string { - out := []string{} - seen := map[string]struct{}{} - for _, entry := range values { - cleaned := normalizeTasksExcludedFolder(entry) - if cleaned == "" { - continue - } - if _, dup := seen[cleaned]; dup { - continue - } - seen[cleaned] = struct{}{} - out = append(out, cleaned) - } - return out -} - -// normalizeTasksSettings carries the Tasks-system settings through the -// settings round-trip: a validated exclusion list, or nil so vault.json stays -// free of empty stubs. -func normalizeTasksSettings(value *TasksSettings) *TasksSettings { - if value == nil { - return nil - } - excluded := normalizeTasksExcludedFolders(value.ExcludedFolders) - if len(excluded) == 0 { - return nil - } - return &TasksSettings{ExcludedFolders: excluded} -} - -// tasksExcludedFolders reads the exclusion list off already-normalized -// settings. -func tasksExcludedFolders(settings VaultSettings) []string { - if settings.Tasks == nil { - return nil - } - return settings.Tasks.ExcludedFolders -} - -// isPathExcludedFromTasks reports whether a vault-relative POSIX path lives -// inside any excluded folder. Segment-prefix match, case-sensitive like the -// rest of the vault layer: `inbox/Books` excludes `inbox/Books/x.md` and -// `inbox/Books/sub/y.md`, never `inbox/Bookshelf.md`. -func isPathExcludedFromTasks(relPath string, excluded []string) bool { - for _, folder := range excluded { - if relPath == folder || strings.HasPrefix(relPath, folder+"/") { - return true - } - } - return false -} diff --git a/apps/server/internal/vault/tasks_exclude_test.go b/apps/server/internal/vault/tasks_exclude_test.go deleted file mode 100644 index 1d3ae4e7..00000000 --- a/apps/server/internal/vault/tasks_exclude_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package vault - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -// #458: mirrors tasks-excluded-folders.test.ts in shared-domain; the rules -// must stay byte-compatible across runtimes. - -func TestNormalizeTasksExcludedFolders(t *testing.T) { - got := normalizeTasksExcludedFolders([]string{ - "inbox/Books", - "../x", - "inbox/Books/", - "/inbox//Books", - "inbox\\Books", - "archive/Old", - "./inbox", - " ", - }) - want := []string{"inbox/Books", "archive/Old"} - if !reflect.DeepEqual(got, want) { - t.Errorf("normalizeTasksExcludedFolders = %v, want %v", got, want) - } -} - -func TestIsPathExcludedFromTasks(t *testing.T) { - excluded := []string{"inbox/Books", "archive/Old Projects"} - cases := []struct { - path string - want bool - }{ - {"inbox/Books/dune.md", true}, - {"inbox/Books/scifi/blindsight.md", true}, - {"archive/Old Projects/site.md", true}, - {"inbox/Bookshelf.md", false}, - {"inbox/Books.md", false}, - {"inbox/books/dune.md", false}, // case-sensitive - {"quick/note.md", false}, - } - for _, tc := range cases { - if got := isPathExcludedFromTasks(tc.path, excluded); got != tc.want { - t.Errorf("isPathExcludedFromTasks(%q) = %v, want %v", tc.path, got, tc.want) - } - } - if isPathExcludedFromTasks("inbox/Books/dune.md", nil) { - t.Error("empty exclusion list must never match") - } -} - -// End to end through New → GetSettings → ScanTasks, so the settings cache and -// cloneSettings are on the hook too: the defensive copy silently dropped the -// Tasks object once, and only a live-server smoke test caught it. -func TestScanTasksHonorsExcludedFolders(t *testing.T) { - root := t.TempDir() - mustWrite := func(rel, body string) { - t.Helper() - abs := filepath.Join(root, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - } - mustWrite(".zennotes/vault.json", `{"tasks":{"excludedFolders":["inbox/Books"]}}`) - mustWrite("inbox/Real Work.md", "- [ ] ship it\n") - mustWrite("inbox/Books/Backlog.md", "- [ ] Excession\n- [ ] Player of Games\n") - - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - - tasks, err := v.ScanTasks() - if err != nil { - t.Fatal(err) - } - for _, tk := range tasks { - if isPathExcludedFromTasks(tk.SourcePath, []string{"inbox/Books"}) { - t.Errorf("excluded-folder task leaked into the default scan: %s", tk.ID) - } - } - if len(tasks) != 1 { - t.Fatalf("expected only the Real Work task, got %d tasks", len(tasks)) - } - - all, err := v.ScanTasksWith(ParseTasksOptions{IncludeExcluded: true}) - if err != nil { - t.Fatal(err) - } - if len(all) != 3 { - t.Fatalf("expected 3 tasks with IncludeExcluded, got %d", len(all)) - } -} diff --git a/apps/server/internal/vault/templates.go b/apps/server/internal/vault/templates.go deleted file mode 100644 index 0cf9c752..00000000 --- a/apps/server/internal/vault/templates.go +++ /dev/null @@ -1,229 +0,0 @@ -package vault - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" -) - -// Custom-template file I/O for a vault served over HTTP. Templates are plain -// `.md` files in the flat `.zennotes/templates/` directory, and this layer is -// deliberately parse-free: it moves raw bytes, and the client owns the -// frontmatter format (`packages/shared-domain/src/template-files.ts`). -// -// SYNCED COPY: the filename rules here (safeTemplateSlug, uniqueTemplateSlug, -// resolveTemplatePath) mirror `apps/desktop/src/main/templates.ts` byte for -// byte. A vault served remotely today is opened locally tomorrow, and a -// template's id is `custom:`, so both sides must land the same -// bytes on the same filename. Change one, change both. - -const templatesRelDir = ".zennotes/templates" - -var ErrInvalidTemplate = errors.New("invalid template request") - -// CustomTemplateFile matches bridge-contract's CustomTemplateFile. -type CustomTemplateFile struct { - SourcePath string `json:"sourcePath"` - Raw string `json:"raw"` -} - -// WriteTemplateInput matches bridge-contract's WriteTemplateInput. -type WriteTemplateInput struct { - Slug string `json:"slug"` - Raw string `json:"raw"` - PreviousSourcePath string `json:"previousSourcePath,omitempty"` -} - -func templateDir(root string) string { - return filepath.Join(root, ".zennotes", "templates") -} - -func templateSourcePath(name string) string { - return templatesRelDir + "/" + name -} - -func templateFilenameStem(sourcePath string) string { - name := sourcePath[strings.LastIndex(sourcePath, "/")+1:] - if strings.EqualFold(filepath.Ext(name), ".md") { - return name[:len(name)-len(".md")] - } - return name -} - -// safeTemplateSlug keeps lowercase letters, digits and dashes; every run of -// anything else becomes one dash, and leading and trailing dashes go. Dashes -// that were already there stay as typed (`a--b` remains `a--b`): that is what -// the desktop does, and the renderer's slugifyTemplateName has collapsed them -// before the request is made anyway. -func safeTemplateSlug(slug string) string { - var out strings.Builder - inRun := false - for _, r := range strings.ToLower(slug) { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { - if inRun { - out.WriteByte('-') - inRun = false - } - out.WriteRune(r) - continue - } - inRun = true - } - if inRun { - out.WriteByte('-') - } - cleaned := strings.Trim(out.String(), "-") - if cleaned == "" { - return "template" - } - return cleaned -} - -// resolveTemplatePath turns a vault-relative sourcePath into an absolute one, -// refusing anything outside the flat templates directory (no traversal, no -// subdirectories, no symlinked escape) and anything that is not a `.md` file. -func (v *Vault) resolveTemplatePath(sourcePath string) (string, error) { - abs, err := SafeJoin(v.root, sourcePath) - if err != nil { - return "", err - } - rel, err := filepath.Rel(templateDir(v.root), abs) - if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { - return "", fmt.Errorf("%w: refusing template path outside templates dir: %s", ErrInvalidTemplate, sourcePath) - } - if !strings.EqualFold(filepath.Ext(rel), ".md") { - return "", fmt.Errorf("%w: template path must be a .md file: %s", ErrInvalidTemplate, sourcePath) - } - return abs, nil -} - -// uniqueTemplateSlug picks a free slug. Editing the same file keeps its slug -// (the write lands in place); otherwise the slug is de-duplicated against the -// files already there (adr, adr-2, adr-3, ...). -func uniqueTemplateSlug(dir, base, previousSourcePath string) string { - prevStem := "" - if previousSourcePath != "" { - prevStem = templateFilenameStem(previousSourcePath) - } - candidate := base - for n := 2; ; n++ { - if candidate == prevStem { - return candidate - } - if _, err := os.Lstat(filepath.Join(dir, candidate+".md")); errors.Is(err, os.ErrNotExist) { - return candidate - } - candidate = fmt.Sprintf("%s-%d", base, n) - } -} - -// ListTemplates returns every custom template with its raw bytes. A vault -// without a templates directory has no templates rather than an error, and -// an unreadable file is skipped, as the desktop does. -func (v *Vault) ListTemplates() ([]CustomTemplateFile, error) { - v.mu.RLock() - defer v.mu.RUnlock() - entries, err := os.ReadDir(templateDir(v.root)) - if errors.Is(err, os.ErrNotExist) { - return []CustomTemplateFile{}, nil - } - if err != nil { - return nil, err - } - out := make([]CustomTemplateFile, 0, len(entries)) - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".md") { - continue - } - sourcePath := templateSourcePath(name) - abs, err := v.resolveTemplatePath(sourcePath) - if err != nil { - continue - } - raw, err := os.ReadFile(abs) - if err != nil { - continue - } - out = append(out, CustomTemplateFile{SourcePath: sourcePath, Raw: string(raw)}) - } - sort.Slice(out, func(i, j int) bool { return out[i].SourcePath < out[j].SourcePath }) - return out, nil -} - -func (v *Vault) ReadTemplate(sourcePath string) (string, error) { - v.mu.RLock() - defer v.mu.RUnlock() - abs, err := v.resolveTemplatePath(sourcePath) - if err != nil { - return "", err - } - raw, err := os.ReadFile(abs) - if err != nil { - return "", err - } - return string(raw), nil -} - -// WriteTemplate saves a template under a slug derived from the request, and -// removes the file it replaces when an edit changed the slug. The previous -// path is validated before anything is written, so a bad one cannot leave a -// stray new file behind. -func (v *Vault) WriteTemplate(input WriteTemplateInput) (CustomTemplateFile, error) { - v.mu.Lock() - defer v.mu.Unlock() - var previous string - if input.PreviousSourcePath != "" { - abs, err := v.resolveTemplatePath(input.PreviousSourcePath) - if err != nil { - return CustomTemplateFile{}, err - } - previous = abs - } - dir := templateDir(v.root) - slug := uniqueTemplateSlug(dir, safeTemplateSlug(input.Slug), input.PreviousSourcePath) - sourcePath := templateSourcePath(slug + ".md") - abs, err := v.resolveTemplatePath(sourcePath) - if err != nil { - return CustomTemplateFile{}, err - } - if err := writeFileAtomic(abs, []byte(input.Raw), v.fileMode, v.dirMode); err != nil { - return CustomTemplateFile{}, err - } - if previous != "" && previous != abs { - // On a case-insensitive filesystem two differently-cased paths can name - // the SAME file, and writeFileAtomic just landed the new content on it; - // a spelling compare would then delete the template that was just - // saved. Compare file identity, not path strings. - sameFile := false - if prevInfo, statErr := os.Stat(previous); statErr == nil { - if newInfo, statErr := os.Stat(abs); statErr == nil && os.SameFile(prevInfo, newInfo) { - sameFile = true - } - } - if !sameFile { - if err := os.Remove(previous); err != nil && !errors.Is(err, os.ErrNotExist) { - return CustomTemplateFile{}, err - } - } - } - return CustomTemplateFile{SourcePath: sourcePath, Raw: input.Raw}, nil -} - -// DeleteTemplate removes a template; a file that is already gone is a -// success, as it is on the desktop. -func (v *Vault) DeleteTemplate(sourcePath string) error { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := v.resolveTemplatePath(sourcePath) - if err != nil { - return err - } - if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil -} diff --git a/apps/server/internal/vault/templates_test.go b/apps/server/internal/vault/templates_test.go deleted file mode 100644 index d6bff8af..00000000 --- a/apps/server/internal/vault/templates_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package vault - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" -) - -func templateTestVault(t *testing.T) (*Vault, string) { - t.Helper() - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { - t.Fatal(err) - } - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - return v, root -} - -// The slug rules are a synced copy of the desktop module; these cases are the -// desktop's behaviour, spelled out so a drift on either side fails here. -func TestSafeTemplateSlugMirrorsDesktop(t *testing.T) { - cases := map[string]string{ - "Réunion Hebdo!": "r-union-hebdo", - " ADR ": "adr", - "a--b": "a--b", - "a - b": "a---b", - "Weekly Review 2026": "weekly-review-2026", - "UPPER_case.name": "upper-case-name", - "": "template", - "---": "template", - "!!!": "template", - } - for input, want := range cases { - if got := safeTemplateSlug(input); got != want { - t.Errorf("safeTemplateSlug(%q) = %q, want %q", input, got, want) - } - } - stems := map[string]string{ - ".zennotes/templates/adr.md": "adr", - ".zennotes/templates/adr.MD": "adr", - ".zennotes/templates/x.y.md": "x.y", - "adr": "adr", - } - for input, want := range stems { - if got := templateFilenameStem(input); got != want { - t.Errorf("templateFilenameStem(%q) = %q, want %q", input, got, want) - } - } -} - -func TestWriteTemplateDedupesAndKeepsSlugOnEdit(t *testing.T) { - v, root := templateTestVault(t) - - first, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1"}) - if err != nil { - t.Fatal(err) - } - if first.SourcePath != ".zennotes/templates/adr.md" { - t.Fatalf("first sourcePath = %q", first.SourcePath) - } - second, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v2"}) - if err != nil { - t.Fatal(err) - } - if second.SourcePath != ".zennotes/templates/adr-2.md" { - t.Fatalf("duplicate slug landed on %q, want adr-2.md", second.SourcePath) - } - - edited, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1 edited", PreviousSourcePath: first.SourcePath}) - if err != nil { - t.Fatal(err) - } - if edited.SourcePath != first.SourcePath { - t.Fatalf("editing in place moved the file to %q", edited.SourcePath) - } - body, err := os.ReadFile(filepath.Join(root, ".zennotes", "templates", "adr.md")) - if err != nil || string(body) != "v1 edited" { - t.Fatalf("edit did not land: %q (%v)", body, err) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "adr-3.md")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("editing in place must not create adr-3.md: %v", err) - } - - files, err := v.ListTemplates() - if err != nil { - t.Fatal(err) - } - if len(files) != 2 || files[0].SourcePath != ".zennotes/templates/adr-2.md" || files[1].SourcePath != ".zennotes/templates/adr.md" { - t.Fatalf("list = %+v", files) - } - entries, _ := os.ReadDir(filepath.Join(root, ".zennotes", "templates")) - for _, entry := range entries { - if strings.HasSuffix(entry.Name(), ".tmp") { - t.Fatalf("atomic write left its scratch file behind: %s", entry.Name()) - } - } -} - -func TestWriteTemplateRenameRemovesPrevious(t *testing.T) { - v, root := templateTestVault(t) - if _, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1"}); err != nil { - t.Fatal(err) - } - renamed, err := v.WriteTemplate(WriteTemplateInput{Slug: "Decision Record", Raw: "v2", PreviousSourcePath: ".zennotes/templates/adr.md"}) - if err != nil { - t.Fatal(err) - } - if renamed.SourcePath != ".zennotes/templates/decision-record.md" { - t.Fatalf("renamed sourcePath = %q", renamed.SourcePath) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "adr.md")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("previous file should be gone after the rename: %v", err) - } - raw, err := v.ReadTemplate(renamed.SourcePath) - if err != nil || raw != "v2" { - t.Fatalf("read after rename = %q (%v)", raw, err) - } - // Deleting twice is fine: the desktop's rm --force semantics. - if err := v.DeleteTemplate(renamed.SourcePath); err != nil { - t.Fatal(err) - } - if err := v.DeleteTemplate(renamed.SourcePath); err != nil { - t.Fatalf("second delete should be a no-op, got %v", err) - } - if _, err := v.ReadTemplate(renamed.SourcePath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("read after delete = %v, want ErrNotExist", err) - } -} - -func TestTemplatePathsMustStayInsideTemplatesDir(t *testing.T) { - v, root := templateTestVault(t) - for _, bad := range []string{ - "../../etc/passwd", - "/etc/passwd", - ".zennotes/templates/../../inbox/A.md", - ".zennotes/templates/sub/dir.md", - ".zennotes/templates/not-markdown.txt", - ".zennotes/templates", - "inbox/A.md", - "", - } { - if _, err := v.ReadTemplate(bad); !errors.Is(err, ErrInvalidTemplate) && !errors.Is(err, ErrPathEscape) { - t.Errorf("ReadTemplate(%q) = %v, want an invalid-path error", bad, err) - } - if err := v.DeleteTemplate(bad); !errors.Is(err, ErrInvalidTemplate) && !errors.Is(err, ErrPathEscape) { - t.Errorf("DeleteTemplate(%q) = %v, want an invalid-path error", bad, err) - } - } - // A template delete can never reach a note. - if _, err := os.Stat(filepath.Join(root, "inbox", "A.md")); err != nil { - t.Fatalf("note went missing: %v", err) - } - // A bad previous path fails before anything is written. - if _, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1", PreviousSourcePath: "inbox/A.md"}); !errors.Is(err, ErrInvalidTemplate) { - t.Fatalf("write with a note as previous = %v, want ErrInvalidTemplate", err) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "templates", "adr.md")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("rejected write left a file behind: %v", err) - } -} - -func TestTemplatesRejectSymlinkedTemplatesDir(t *testing.T) { - v, root := templateTestVault(t) - external := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, ".zennotes"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.Symlink(external, filepath.Join(root, ".zennotes", "templates")); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - if _, err := v.WriteTemplate(WriteTemplateInput{Slug: "adr", Raw: "v1"}); !errors.Is(err, ErrPathEscape) { - t.Fatalf("write through a symlinked templates dir = %v, want ErrPathEscape", err) - } - if entries, _ := os.ReadDir(external); len(entries) != 0 { - t.Fatalf("write escaped into %s: %v", external, entries) - } - if err := os.WriteFile(filepath.Join(external, "leak.md"), []byte("outside"), 0o600); err != nil { - t.Fatal(err) - } - files, err := v.ListTemplates() - if err != nil { - t.Fatal(err) - } - if len(files) != 0 { - t.Fatalf("list followed the symlink: %+v", files) - } -} - -func TestListTemplatesSkipsDotfilesDirsAndNonMarkdown(t *testing.T) { - v, root := templateTestVault(t) - dir := filepath.Join(root, ".zennotes", "templates") - if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o700); err != nil { - t.Fatal(err) - } - for name, body := range map[string]string{ - "adr.md": "adr", - "Weekly.MD": "weekly", - ".draft.md": "hidden", - "notes.txt": "text", - "nested/x.md": "nested", - } { - if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { - t.Fatal(err) - } - } - files, err := v.ListTemplates() - if err != nil { - t.Fatal(err) - } - if len(files) != 2 || files[0].SourcePath != ".zennotes/templates/Weekly.MD" || files[0].Raw != "weekly" || files[1].SourcePath != ".zennotes/templates/adr.md" { - t.Fatalf("list = %+v", files) - } - empty, root2 := templateTestVault(t) - _ = root2 - files, err = empty.ListTemplates() - if err != nil || len(files) != 0 { - t.Fatalf("vault without a templates dir: %+v, %v", files, err) - } -} diff --git a/apps/server/internal/vault/types.go b/apps/server/internal/vault/types.go deleted file mode 100644 index 79bfde3c..00000000 --- a/apps/server/internal/vault/types.go +++ /dev/null @@ -1,469 +0,0 @@ -package vault - -import ( - "path/filepath" - "strings" -) - -// Types in this file mirror the TypeScript interfaces in -// `src/shared/ipc.ts` and `src/shared/tasks.ts`. The JSON tags must -// match the TS field names exactly so the client can consume the -// responses without translation. - -type NoteFolder string -type PrimaryNotesLocation string -type FolderIconID string -type FolderColorID string - -const ( - FolderInbox NoteFolder = "inbox" - FolderQuick NoteFolder = "quick" - FolderArchive NoteFolder = "archive" - FolderTrash NoteFolder = "trash" - - PrimaryNotesInbox PrimaryNotesLocation = "inbox" - PrimaryNotesRoot PrimaryNotesLocation = "root" - DefaultDailyNotesDirectory = "Daily Notes" - DefaultDailyNoteTitlePattern = "yyyy-MM-dd" - DefaultDailyNoteLocale = "system" - DefaultWeeklyNotesDirectory = "Weekly Notes" - DefaultWeeklyNoteTitlePattern = "yyyy-'W'ww" - DefaultWeeklyNoteLocale = "system" - DefaultMonthlyNotesDirectory = "Monthly Notes" - DefaultMonthlyNoteTitlePattern = "yyyy-MM" - DefaultMonthlyNoteLocale = "system" -) - -func IsValidFolder(f NoteFolder) bool { - switch f { - case FolderInbox, FolderQuick, FolderArchive, FolderTrash: - return true - } - return false -} - -var AllFolders = []NoteFolder{FolderInbox, FolderQuick, FolderArchive, FolderTrash} - -var defaultFolderPaths = map[NoteFolder]string{ - FolderInbox: string(FolderInbox), - FolderQuick: string(FolderQuick), - FolderArchive: string(FolderArchive), - FolderTrash: string(FolderTrash), -} - -var reservedFolderPathNames = map[string]struct{}{ - "assets": {}, - ".zennotes": {}, - "attachements": {}, - "_assets": {}, - "deleted-assets": {}, - "comments": {}, -} - -func isValidFolderPath(p string) bool { - if p == "" || len(p) > 128 { - return false - } - if strings.Contains(p, "/") || strings.Contains(p, "\\") { - return false - } - if p == "." || p == ".." || strings.HasPrefix(p, ".") { - return false - } - for _, c := range p { - if c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || - c == '|' || c == '#' || c == '^' || c == '[' || c == ']' { - return false - } - } - if _, reserved := reservedFolderPathNames[strings.ToLower(p)]; reserved { - return false - } - return true -} - -// NormalizeSystemFolderPaths validates a raw systemFolderPaths map the way the -// vault settings reader does. Exported so callers that read vault.json without -// going through GetSettings (the watcher) classify against the same paths the -// vault itself uses. Mirrors normalizeSystemFolderPaths in -// packages/shared-domain/src/system-folder-paths.ts. -func NormalizeSystemFolderPaths(raw map[string]string) map[string]string { - return normalizeSystemFolderPaths(raw) -} - -func normalizeSystemFolderPaths(raw map[string]string) map[string]string { - if raw == nil { - return nil - } - next := map[string]string{} - for _, folder := range AllFolders { - val, ok := raw[string(folder)] - if !ok || val == "" { - continue - } - val = strings.TrimSpace(val) - if !isValidFolderPath(val) { - continue - } - if val == string(folder) { - continue - } - // Never let a folder claim ANOTHER folder's default name, even when that - // other folder has moved out of the way: {inbox: "archive", archive: - // "inbox"} resolves without collision, and the swap it describes reads - // backwards on every surface that classifies a path by its top segment - // (and in every other app looking at the same directory). - if claimsAnotherDefaultName(folder, val) { - continue - } - next[string(folder)] = val - } - changed := true - for changed { - changed = false - for _, folder := range AllFolders { - val, ok := next[string(folder)] - if !ok { - continue - } - lower := strings.ToLower(val) - for _, other := range AllFolders { - if other == folder { - continue - } - otherResolved := strings.ToLower(resolveFolderPath(other, next)) - if lower == otherResolved { - delete(next, string(folder)) - changed = true - break - } - } - } - } - if len(next) == 0 { - return nil - } - return next -} - -func claimsAnotherDefaultName(folder NoteFolder, val string) bool { - lower := strings.ToLower(val) - for _, other := range AllFolders { - if other == folder { - continue - } - if lower == defaultFolderPaths[other] { - return true - } - } - return false -} - -func resolveFolderPath(folder NoteFolder, paths map[string]string) string { - if p, ok := paths[string(folder)]; ok { - return p - } - return defaultFolderPaths[folder] -} - -// SystemFolderForDirName returns the system folder that owns a top-level -// directory name, or false when the name belongs to no system folder (an -// ordinary user folder, an assets dir, anything else). -// -// This is THE classification rule for a path's first segment, because only the -// RESOLVED name of each folder counts: with inbox remapped to `01 - Entry`, -// `01 - Entry/` is the inbox and a directory literally named `inbox/` is just a -// user folder. Case-insensitive, since macOS and Windows preserve whatever case -// the directory was created with (#186). Mirrors systemFolderForDirName in -// packages/shared-domain/src/system-folder-paths.ts. -func SystemFolderForDirName(name string, paths map[string]string) (NoteFolder, bool) { - lower := strings.ToLower(name) - for _, folder := range AllFolders { - if strings.ToLower(resolveFolderPath(folder, paths)) == lower { - return folder, true - } - } - return "", false -} - -func FolderForRelativePath(rel string) (NoteFolder, bool) { - return FolderForRelativePathWithSettings(rel, nil) -} - -func FolderForRelativePathWithSettings(rel string, paths map[string]string) (NoteFolder, bool) { - normalized := filepath.ToSlash(rel) - top := strings.SplitN(normalized, "/", 2)[0] - if top == "" || strings.HasPrefix(top, ".") { - return "", false - } - if folder, ok := SystemFolderForDirName(top, paths); ok { - return folder, true - } - // Only the dirs that are reserved no matter where the system folders live. - // A default name whose folder has moved (`inbox/` once inbox is `bucket`) - // is an ordinary user folder and must classify as one. - if _, reserved := reservedNonSystemRootNames[top]; reserved { - return "", false - } - return FolderInbox, true -} - -type DateNotePatternSettings struct { - Directory string `json:"directory"` - TitlePattern string `json:"titlePattern,omitempty"` - Locale string `json:"locale,omitempty"` -} - -type DailyNotesSettings struct { - Enabled bool `json:"enabled"` - Directory string `json:"directory"` - TitlePattern string `json:"titlePattern,omitempty"` - Locale string `json:"locale,omitempty"` - LegacyPatterns []DateNotePatternSettings `json:"legacyPatterns,omitempty"` - TemplateID string `json:"templateId,omitempty"` - // Pointers so an absent field round-trips as "unset" (the TS client applies - // the real default — true for TasksDueOnNoteDate, false for rollover). These - // drive purely client-side behavior; the server only persists them. - TasksDueOnNoteDate *bool `json:"tasksDueOnNoteDate,omitempty"` - RolloverUnfinishedTasks *bool `json:"rolloverUnfinishedTasks,omitempty"` -} - -type WeeklyNotesSettings struct { - Enabled bool `json:"enabled"` - Directory string `json:"directory"` - TitlePattern string `json:"titlePattern,omitempty"` - Locale string `json:"locale,omitempty"` - LegacyPatterns []DateNotePatternSettings `json:"legacyPatterns,omitempty"` - TemplateID string `json:"templateId,omitempty"` -} - -type MonthlyNotesSettings struct { - Enabled bool `json:"enabled"` - Directory string `json:"directory"` - TitlePattern string `json:"titlePattern,omitempty"` - Locale string `json:"locale,omitempty"` - LegacyPatterns []DateNotePatternSettings `json:"legacyPatterns,omitempty"` - TemplateID string `json:"templateId,omitempty"` -} - -// FileLocationMode mirrors shared/ipc.ts FileLocationMode: where a new -// drawing / database / task file is created. -type FileLocationMode string - -const ( - FileLocationPrimary FileLocationMode = "primary" - FileLocationActiveNote FileLocationMode = "active-note" - FileLocationFolder FileLocationMode = "folder" -) - -// FileLocationSetting mirrors shared/ipc.ts FileLocationSetting. Persisted so -// the web client's Drawings / Databases / Tasks location choices survive a -// round-trip instead of being silently dropped by the settings struct (#446). -type FileLocationSetting struct { - Mode FileLocationMode `json:"mode"` - Folder string `json:"folder,omitempty"` -} - -type VaultSettings struct { - PrimaryNotesLocation PrimaryNotesLocation `json:"primaryNotesLocation"` - DailyNotes DailyNotesSettings `json:"dailyNotes"` - WeeklyNotes WeeklyNotesSettings `json:"weeklyNotes"` - MonthlyNotes MonthlyNotesSettings `json:"monthlyNotes"` - DrawingsLocation FileLocationSetting `json:"drawingsLocation"` - DatabasesLocation FileLocationSetting `json:"databasesLocation"` - TasksLocation FileLocationSetting `json:"tasksLocation"` - FolderIcons map[string]FolderIconID `json:"folderIcons"` - // FolderColors are per-folder accent colors, keyed by `folder:subpath` (the - // same key as FolderIcons). Persisted so the web client's recolors survive a - // round-trip instead of being silently dropped. (#379) - FolderColors map[string]FolderColorID `json:"folderColors"` - // Favorites are note paths or `folder:subpath` keys pinned to the top of - // the sidebar. Persisted so the web client's favorites survive a round-trip. - Favorites []string `json:"favorites"` - // Per-system-folder on-disk path overrides (#115). Maps internal folder IDs - // to vault-relative directory names. Absent entries fall back to the default. - SystemFolderPaths map[string]string `json:"systemFolderPaths,omitempty"` - // Tasks-system settings (#458). Mirrors shared/ipc.ts VaultSettings.tasks; - // persisted as a first-class field so a web client's settings write never - // drops a desktop-written exclusion list (the #446/#379 round-trip rule). - Tasks *TasksSettings `json:"tasks,omitempty"` - // Typst preamble settings (#486, #562). Mirrors shared/ipc.ts - // VaultSettings.typstPreambles; a first-class field for the same round-trip - // reason as Tasks above. - TypstPreambles *TypstPreambleSettings `json:"typstPreambles,omitempty"` - // Harper grammar-checker data that belongs to the vault (dictionary words - // and ignored-suggestion hashes). Mirrors shared/ipc.ts VaultSettings.harper; - // a first-class field for the same round-trip reason as Tasks above. - Harper *HarperSettings `json:"harper,omitempty"` -} - -// HarperSettings mirrors shared/ipc.ts VaultSettings.harper. IgnoredLints are -// Harper's unsigned 64-bit context hashes carried as digit strings, because -// the browser clients cannot hold them as numbers without rounding. -type HarperSettings struct { - Words []string `json:"words"` - IgnoredLints []string `json:"ignoredLints"` -} - -// TasksSettings mirrors shared/ipc.ts VaultSettings.tasks (#458). -type TasksSettings struct { - // ExcludedFolders lists vault-relative directory paths (as they exist on - // disk) whose notes never feed the Tasks surfaces. - ExcludedFolders []string `json:"excludedFolders,omitempty"` -} - -// TypstPreambleSettings mirrors shared/ipc.ts VaultSettings.typstPreambles -// (#562). -type TypstPreambleSettings struct { - // Folder names the directory whose notes are Typst preambles, matched at - // any depth. Empty means the default, `typst`. - Folder string `json:"folder,omitempty"` -} - -// NoteMeta — vault-relative note metadata. Mirrors shared/ipc.ts NoteMeta. -type NoteMeta struct { - Path string `json:"path"` - Title string `json:"title"` - Folder NoteFolder `json:"folder"` - SiblingOrder int `json:"siblingOrder"` - CreatedAt int64 `json:"createdAt"` - UpdatedAt int64 `json:"updatedAt"` - Size int64 `json:"size"` - Tags []string `json:"tags"` - Wikilinks []string `json:"wikilinks"` - HasAttachments bool `json:"hasAttachments"` - Excerpt string `json:"excerpt"` -} - -// NoteContent extends NoteMeta with the raw body. -type NoteContent struct { - NoteMeta - Body string `json:"body"` -} - -// NoteComment — sidecar annotation/comment data for a note. -type NoteComment struct { - ID string `json:"id"` - NotePath string `json:"notePath"` - AnchorStart int `json:"anchorStart"` - AnchorEnd int `json:"anchorEnd"` - AnchorText string `json:"anchorText"` - Body string `json:"body"` - CreatedAt int64 `json:"createdAt"` - UpdatedAt int64 `json:"updatedAt"` - ResolvedAt *int64 `json:"resolvedAt"` - // Author is who wrote it: empty for the vault's owner, an assistant's - // name otherwise. ParentID threads a reply under a top-level comment. - // Both mirror shared-domain/note-comments.ts (#738). - Author string `json:"author,omitempty"` - ParentID string `json:"parentId,omitempty"` -} - -// FolderEntry — mirrors shared/ipc.ts FolderEntry. -type FolderEntry struct { - Folder NoteFolder `json:"folder"` - Subpath string `json:"subpath"` - SiblingOrder int `json:"siblingOrder"` -} - -// AssetMeta — mirrors shared/ipc.ts AssetMeta. -type AssetMeta struct { - Path string `json:"path"` - Name string `json:"name"` - Kind string `json:"kind"` - SiblingOrder int `json:"siblingOrder"` - Size int64 `json:"size"` - UpdatedAt int64 `json:"updatedAt"` -} - -// DeletedAsset — mirrors shared/ipc.ts DeletedAsset. The meta file it is -// read from (.zn-deleted.json) is shared with the desktop app: a vault can be -// served remotely today and opened locally tomorrow, so the field set must -// stay byte-compatible with desktop vault.ts. -type DeletedAsset struct { - Path string `json:"path"` - Name string `json:"name"` - UndoToken string `json:"undoToken"` - DeletedAt string `json:"deletedAt,omitempty"` -} - -// ImportedAsset — mirrors shared/ipc.ts ImportedAsset. -type ImportedAsset struct { - Name string `json:"name"` - Path string `json:"path"` - Markdown string `json:"markdown"` - Kind string `json:"kind"` -} - -// VaultInfo — mirrors shared/ipc.ts VaultInfo. -type VaultInfo struct { - Root string `json:"root"` - Name string `json:"name"` -} - -// TextSearchCapabilities — mirrors shared/ipc.ts VaultTextSearchCapabilities. -type TextSearchCapabilities struct { - Ripgrep bool `json:"ripgrep"` - Fzf bool `json:"fzf"` -} - -// TextSearchMatch — mirrors shared/ipc.ts VaultTextSearchMatch. -type TextSearchMatch struct { - Path string `json:"path"` - Title string `json:"title"` - Folder NoteFolder `json:"folder"` - LineNumber int `json:"lineNumber"` - Offset int `json:"offset"` - LineText string `json:"lineText"` -} - -// Task — mirrors shared/tasks.ts VaultTask. -type Task struct { - ID string `json:"id"` - SourcePath string `json:"sourcePath"` - NoteTitle string `json:"noteTitle"` - NoteFolder NoteFolder `json:"noteFolder"` - LineNumber int `json:"lineNumber"` - TaskIndex int `json:"taskIndex"` - RawText string `json:"rawText"` - Content string `json:"content"` - Checked bool `json:"checked"` - // Cancelled is true for a `[-]` task — intentionally abandoned (#450). - Cancelled bool `json:"cancelled,omitempty"` - // InProgress is true for a `[/]` task: started, not finished (#512). - // Unlike Checked/Cancelled it is still open work, so it keeps its place - // in the active buckets on every surface. - InProgress bool `json:"inProgress,omitempty"` - // Forwarded is true for a `[>]` record: the task moved to another note - // and a live copy exists there (#316). Without it, a web client read - // carried tasks as open twice, record and copy alike (#611 review). - Forwarded bool `json:"forwarded,omitempty"` - Due string `json:"due,omitempty"` - Priority string `json:"priority,omitempty"` - Waiting bool `json:"waiting"` - // Fields contains inline @key:value metadata (or a file task's explicit - // frontmatter status) so remote Kanban boards group tasks exactly like the - // desktop parser (#643). Status is the effective status: Fields["status"] - // when set, and "open" for a file task whose frontmatter says nothing, - // which deliberately stays out of Fields (#672). - Fields map[string]string `json:"fields"` - Status string `json:"status,omitempty"` - Tags []string `json:"tags"` - // Kind is how the task is stored: "file" for a whole-note task - // (TaskNotes-style, tagged `task` with metadata in frontmatter) or - // empty/"inline" for a classic `- [ ]` checkbox line. The renderer - // branches its toggle logic on this. - Kind string `json:"kind,omitempty"` - // Scheduled and CompletedDate are file-task-only frontmatter dates - // (YYYY-MM-DD). They mirror the TS VaultTask shape. - Scheduled string `json:"scheduled,omitempty"` - CompletedDate string `json:"completedDate,omitempty"` -} - -// ChangeEvent — mirrors shared/ipc.ts VaultChangeEvent. -type ChangeEvent struct { - Kind string `json:"kind"` // "add" | "change" | "unlink" - Path string `json:"path"` - Folder NoteFolder `json:"folder"` - Scope string `json:"scope,omitempty"` -} diff --git a/apps/server/internal/vault/typst_preamble.go b/apps/server/internal/vault/typst_preamble.go deleted file mode 100644 index 88f93b49..00000000 --- a/apps/server/internal/vault/typst_preamble.go +++ /dev/null @@ -1,90 +0,0 @@ -package vault - -import "strings" - -// The vault-level Typst preamble folder (#486, configurable since #562). -// Byte-for-byte mirror of packages/shared-domain/src/typst-preamble-folder.ts: -// change both together. The setting names a single directory NAME matched at -// any depth, so `inbox/typst/physics.md` and `archive/notes/typst/maths.md` -// are both preambles. -// -// Preamble notes hold Typst source, not prose: `#let vec(x) = bold(x)` and the -// `#var` references inside formulas are variables, and indexing them filled a -// vault's tag list with `let` and every variable name. Tags are the only thing -// skipped; a preamble keeps its excerpt, wikilinks and searchability. - -// DefaultTypstPreambleFolder is the folder name used when the vault says -// nothing. -const DefaultTypstPreambleFolder = "typst" - -const maxTypstPreambleFolderLength = 128 - -// Same character rules as a system folder path (#115): one directory name, no -// separators, nothing that needs escaping on any platform we ship to. -const invalidTypstPreambleFolderChars = `\/:*?"<>|#^[]` - -// normalizeTypstPreambleFolder validates a configured folder name: exactly one -// directory segment, no traversal, no dotfiles. Returns "" when the value is -// unusable and the caller should fall back to the default. -func normalizeTypstPreambleFolder(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" || len(trimmed) > maxTypstPreambleFolderLength { - return "" - } - if trimmed == "." || trimmed == ".." || strings.HasPrefix(trimmed, ".") { - return "" - } - if strings.ContainsAny(trimmed, invalidTypstPreambleFolderChars) { - return "" - } - return trimmed -} - -// resolveTypstPreambleFolder returns the folder name in effect for the given -// already-parsed settings. Never fails: anything malformed resolves to the -// default. -func resolveTypstPreambleFolder(settings VaultSettings) string { - if settings.TypstPreambles == nil { - return DefaultTypstPreambleFolder - } - if folder := normalizeTypstPreambleFolder(settings.TypstPreambles.Folder); folder != "" { - return folder - } - return DefaultTypstPreambleFolder -} - -// normalizeTypstPreambleSettings carries the preamble settings through the -// settings round-trip. Returns nil for the default folder so an untouched -// vault.json never grows an empty stub, matching normalizeTasksSettings. -func normalizeTypstPreambleSettings(value *TypstPreambleSettings) *TypstPreambleSettings { - if value == nil { - return nil - } - folder := normalizeTypstPreambleFolder(value.Folder) - if folder == "" || folder == DefaultTypstPreambleFolder { - return nil - } - return &TypstPreambleSettings{Folder: folder} -} - -// isTypstPreamblePath reports whether a vault-relative POSIX path is a -// preamble note, i.e. sits in a directory with the configured name at any -// depth. Case-insensitive, matching how the rest of the preamble layer treats -// tags and titles. -func isTypstPreamblePath(relPath, folder string) bool { - name := strings.ToLower(folder) - if name == "" { - return false - } - parts := strings.Split(relPath, "/") - if len(parts) < 2 { - return false - } - // The last part is the file itself, so look for the folder among the parents. - for _, part := range parts[:len(parts)-1] { - if strings.ToLower(strings.TrimSpace(part)) == name { - return true - } - } - return false -} diff --git a/apps/server/internal/vault/typst_preamble_test.go b/apps/server/internal/vault/typst_preamble_test.go deleted file mode 100644 index b15d8e51..00000000 --- a/apps/server/internal/vault/typst_preamble_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package vault - -import "testing" - -// These cases mirror packages/shared-domain/src/typst-preamble-folder.test.ts -// one for one. When either side gains a rule, add it here too: the two -// implementations only stay compatible if they are tested on the same inputs. - -func TestNormalizeTypstPreambleFolder(t *testing.T) { - valid := map[string]string{ - "typst": "typst", - " Preambles ": "Preambles", - "math defs": "math defs", - } - for input, want := range valid { - if got := normalizeTypstPreambleFolder(input); got != want { - t.Errorf("normalizeTypstPreambleFolder(%q) = %q, want %q", input, got, want) - } - } - - invalid := []string{ - "a/b", `a\b`, "/typst", ".", "..", ".hidden", "", " ", - "a:b", "a*b", "a#b", "a[b]", - } - for _, input := range invalid { - if got := normalizeTypstPreambleFolder(input); got != "" { - t.Errorf("normalizeTypstPreambleFolder(%q) = %q, want \"\"", input, got) - } - } - - long := make([]byte, 129) - for i := range long { - long[i] = 'x' - } - if got := normalizeTypstPreambleFolder(string(long)); got != "" { - t.Errorf("129-char name accepted: %q", got) - } - if got := normalizeTypstPreambleFolder(string(long[:128])); got != string(long[:128]) { - t.Errorf("128-char name rejected") - } -} - -func TestResolveTypstPreambleFolder(t *testing.T) { - if got := resolveTypstPreambleFolder(VaultSettings{}); got != DefaultTypstPreambleFolder { - t.Errorf("absent settings = %q, want %q", got, DefaultTypstPreambleFolder) - } - bad := VaultSettings{TypstPreambles: &TypstPreambleSettings{Folder: "a/b"}} - if got := resolveTypstPreambleFolder(bad); got != DefaultTypstPreambleFolder { - t.Errorf("invalid folder = %q, want the default", got) - } - ok := VaultSettings{TypstPreambles: &TypstPreambleSettings{Folder: "Preambles"}} - if got := resolveTypstPreambleFolder(ok); got != "Preambles" { - t.Errorf("override = %q, want Preambles", got) - } -} - -func TestNormalizeTypstPreambleSettings(t *testing.T) { - // The default never persists, so an untouched vault.json grows no stub. - for _, in := range []*TypstPreambleSettings{ - nil, - {Folder: "typst"}, - {Folder: ""}, - {Folder: "a/b"}, - } { - if got := normalizeTypstPreambleSettings(in); got != nil { - t.Errorf("normalizeTypstPreambleSettings(%v) = %+v, want nil", in, got) - } - } - got := normalizeTypstPreambleSettings(&TypstPreambleSettings{Folder: " Preambles "}) - if got == nil || got.Folder != "Preambles" { - t.Errorf("override lost: %+v", got) - } -} - -func TestIsTypstPreamblePath(t *testing.T) { - cases := []struct { - path string - folder string - want bool - }{ - {"typst/physics.md", "typst", true}, - {"inbox/typst/physics.md", "typst", true}, - {"archive/notes/TYPST/maths.md", "typst", true}, - // The file itself is never the folder. - {"inbox/typst.md", "typst", false}, - {"typst", "typst", false}, - // Exact segment match, not a prefix. - {"inbox/typstish/x.md", "typst", false}, - {"inbox/my-typst/x.md", "typst", false}, - // A renamed folder moves the exclusion with it. - {"inbox/Preambles/physics.md", "Preambles", true}, - {"inbox/typst/physics.md", "Preambles", false}, - // An empty name is inert rather than matching everything. - {"inbox/typst/physics.md", "", false}, - } - for _, c := range cases { - if got := isTypstPreamblePath(c.path, c.folder); got != c.want { - t.Errorf("isTypstPreamblePath(%q, %q) = %v, want %v", c.path, c.folder, got, c.want) - } - } -} diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go deleted file mode 100644 index a52bfcff..00000000 --- a/apps/server/internal/vault/vault.go +++ /dev/null @@ -1,2991 +0,0 @@ -package vault - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "hash/fnv" - "io" - "io/fs" - "math" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -const ( - // AssetsDir is the canonical top-level folder for assets; attachements/_assets - // are recognized legacy dirs. Asset migration runs on the desktop (#185). - AssetsDir = "assets" - PrimaryAttachmentsDir = "attachements" - internalVaultDir = ".zennotes" - vaultSettingsFile = "vault.json" - noteMetaCacheFile = "note-meta-cache-v1.json" - noteMetaCacheVersion = 1 - noteCommentsDir = "comments" - noteCommentsSuffix = ".comments.json" - noteMetaReadLimit = 64 - // formDirSuffix marks a database folder (`.base/`), a self-contained - // folder holding data.csv, schema.json, and record-page notes. Databases are - // a desktop-only feature; the server hides these folders (it neither serves - // the grid nor exposes the internals as loose notes/assets). - formDirSuffix = ".base" -) - -// isFormDirName reports whether a folder name marks a database folder. -func isFormDirName(name string) bool { - return strings.HasSuffix(strings.ToLower(name), formDirSuffix) -} - -// excalidrawExt marks a standalone Excalidraw drawing — the native Excalidraw -// JSON scene format. Drawings are a first-class file type alongside Markdown -// notes: listed in the sidebar (not as assets) and opened in a dedicated editor. -const excalidrawExt = ".excalidraw" - -// isExcalidrawName reports whether a filename is an Excalidraw drawing. -func isExcalidrawName(name string) bool { - return strings.EqualFold(filepath.Ext(name), excalidrawExt) -} - -// noteExt returns the on-disk extension for a note-like file, preserving -// `.excalidraw` for drawings and defaulting to `.md` otherwise. Rename/move/ -// duplicate use it so a drawing never silently becomes a Markdown note. -func noteExt(name string) string { - if isExcalidrawName(name) { - return excalidrawExt - } - return ".md" -} - -// emptyExcalidrawJSON mirrors emptyExcalidrawDocument() in -// packages/shared-domain/src/excalidraw.ts (JSON.stringify, 2-space indent). -const emptyExcalidrawJSON = `{ - "type": "excalidraw", - "version": 2, - "source": "zennotes", - "elements": [], - "appState": {}, - "files": {} -}` - -// ErrAssetTooLarge is returned when an asset upload exceeds the -// vault's MaxAssetBytes limit. -var ErrAssetTooLarge = errors.New("asset exceeds maximum size") - -var legacyAttachmentsDirs = []string{PrimaryAttachmentsDir, "_assets"} -var reservedRootNames = map[string]struct{}{ - string(FolderInbox): {}, - string(FolderQuick): {}, - string(FolderArchive): {}, - string(FolderTrash): {}, - AssetsDir: {}, - PrimaryAttachmentsDir: {}, - internalVaultDir: {}, -} - -// reservedNonSystemRootNames is the subset of reservedRootNames that stays -// reserved however the system folders are remapped: asset dirs and our own -// internal dir are never user note folders, while `inbox`/`archive`/… are -// reserved only while a system folder actually resolves there (see -// SystemFolderForDirName). Mirrors RESERVED_NON_SYSTEM_ROOT_NAMES in -// apps/desktop/src/main/vault.ts. -var reservedNonSystemRootNames = map[string]struct{}{ - AssetsDir: {}, - PrimaryAttachmentsDir: {}, - internalVaultDir: {}, -} - -var validFolderIconIDs = map[FolderIconID]struct{}{ - "folder": {}, - "bolt": {}, - "tray": {}, - "archive": {}, - "trash": {}, - "book": {}, - "bookmark": {}, - "calendar": {}, - "briefcase": {}, - "tag": {}, - "document": {}, - "sparkle": {}, - "code": {}, - "user": {}, - "star": {}, - "heart": {}, - "link": {}, - "lightbulb": {}, - "flask": {}, - "graduation": {}, - "music": {}, - "image": {}, - "palette": {}, - "terminal": {}, - "wrench": {}, - "globe": {}, - "map": {}, - "chart": {}, - "home": {}, -} - -// validFolderColorIDs mirrors the FolderColorId presets in -// packages/bridge-contract/src/ipc.ts. (#379) -var validFolderColorIDs = map[FolderColorID]struct{}{ - "red": {}, - "orange": {}, - "amber": {}, - "green": {}, - "teal": {}, - "sky": {}, - "blue": {}, - "indigo": {}, - "violet": {}, - "pink": {}, -} - -func init() { - for _, dir := range legacyAttachmentsDirs { - reservedRootNames[dir] = struct{}{} - reservedNonSystemRootNames[dir] = struct{}{} - } -} - -func shouldHidePrimaryRootName(name string, hidden map[string]struct{}) bool { - _, skip := hidden[name] - return skip -} - -// hiddenPrimaryRootNames returns the directory names skipped while walking the -// vault root in `root` primary mode, where the root itself is the inbox: the -// asset dirs, our internal dir, and the RESOLVED directory of every other -// system folder. A default name whose folder has been remapped away (`quick/` -// once quick lives in `Fast/`) is an ordinary user folder and must not be -// hidden. Mirrors hiddenPrimaryRootNames in apps/desktop/src/main/vault.ts. -func hiddenPrimaryRootNames(settings VaultSettings) map[string]struct{} { - names := map[string]struct{}{} - for name := range reservedNonSystemRootNames { - names[name] = struct{}{} - } - for _, folder := range []NoteFolder{FolderQuick, FolderArchive, FolderTrash} { - names[resolveFolderPath(folder, settings.SystemFolderPaths)] = struct{}{} - } - return names -} - -// Vault encapsulates all operations against a filesystem vault root. -// It is concurrency-safe at the public-method level; internally most -// ops do a short RW-lock dance around mutating operations. -type Vault struct { - root string - fileMode fs.FileMode - dirMode fs.FileMode - maxAssetBytes int64 - mu sync.RWMutex - searchCacheMu sync.Mutex - searchCache *textSearchCache - metaCacheMu sync.Mutex - metaCache map[string]noteMetaCacheEntry - metaCacheLoad bool - metaCacheGen uint64 - // settingsMu guards settingsCache only. GetSettings never takes v.mu (and - // is called with v.mu already held), so the lock order is always v.mu - // first, settingsMu second, and never the reverse. - settingsMu sync.Mutex - settingsCache *cachedVaultSettings -} - -// cachedVaultSettings is a parsed vault.json plus the identity of the bytes it -// was parsed from. -type cachedVaultSettings struct { - settings VaultSettings - modTime time.Time - size int64 -} - -// Options tunes vault filesystem permissions and limits. Zero values -// fall back to a private-by-default profile (0o600 / 0o700, 50 MiB). -type Options struct { - FileMode fs.FileMode - DirMode fs.FileMode - MaxAssetBytes int64 -} - -type textSearchFile struct { - abs string - relPosix string - title string - folder NoteFolder -} - -type textSearchCandidate struct { - match TextSearchMatch - lineLower string -} - -type textSearchCache struct { - signature uint64 - candidates []textSearchCandidate -} - -type noteMetaCacheEntry struct { - mtimeMs float64 - size int64 - meta NoteMeta -} - -type persistedNoteMetaCache struct { - Version int `json:"version"` - Entries []persistedNoteMetaEntry `json:"entries"` -} - -type persistedNoteMetaEntry struct { - Path string `json:"path"` - MtimeMs float64 `json:"mtimeMs"` - Size int64 `json:"size"` - Meta NoteMeta `json:"meta"` -} - -func mtimeMs(info fs.FileInfo) float64 { - return float64(info.ModTime().UnixNano()) / 1_000_000 -} - -func sameMtimeMs(a, b float64) bool { - return math.Abs(a-b) < 0.001 -} - -func (v *Vault) noteMetaCachePath() string { - return filepath.Join(v.root, internalVaultDir, noteMetaCacheFile) -} - -func (v *Vault) invalidateNoteMetaCache() { - v.metaCacheMu.Lock() - v.metaCache = map[string]noteMetaCacheEntry{} - v.metaCacheLoad = false - v.metaCacheGen++ - v.metaCacheMu.Unlock() -} - -func (v *Vault) invalidateTextSearchCache() { - v.searchCacheMu.Lock() - v.searchCache = nil - v.searchCacheMu.Unlock() - v.invalidateNoteMetaCache() -} - -func New(root string, opts Options) (*Vault, error) { - abs, err := filepath.Abs(root) - if err != nil { - return nil, err - } - if opts.FileMode == 0 { - opts.FileMode = 0o600 - } - if opts.DirMode == 0 { - opts.DirMode = 0o700 - } - if opts.MaxAssetBytes <= 0 { - opts.MaxAssetBytes = 50 << 20 - } - if err := os.MkdirAll(abs, opts.DirMode); err != nil { - return nil, err - } - v := &Vault{ - root: abs, - fileMode: opts.FileMode, - dirMode: opts.DirMode, - maxAssetBytes: opts.MaxAssetBytes, - metaCache: map[string]noteMetaCacheEntry{}, - } - if err := v.EnsureLayout(); err != nil { - return nil, err - } - return v, nil -} - -func (v *Vault) Root() string { - return v.root -} - -func (v *Vault) Info() VaultInfo { - return VaultInfo{Root: v.root, Name: filepath.Base(v.root)} -} - -func cloneSettings(settings VaultSettings) VaultSettings { - folderIcons := make(map[string]FolderIconID, len(settings.FolderIcons)) - for key, value := range settings.FolderIcons { - folderIcons[key] = value - } - folderColors := make(map[string]FolderColorID, len(settings.FolderColors)) - for key, value := range settings.FolderColors { - folderColors[key] = value - } - favorites := make([]string, len(settings.Favorites)) - copy(favorites, settings.Favorites) - // Nil stays nil: an absent map marshals away thanks to `omitempty`, and an - // empty one would claim the vault has overrides it does not have. - var systemFolderPaths map[string]string - if settings.SystemFolderPaths != nil { - systemFolderPaths = make(map[string]string, len(settings.SystemFolderPaths)) - for key, value := range settings.SystemFolderPaths { - systemFolderPaths[key] = value - } - } - // Nil stays nil here too (#458); the walker treats an absent Tasks object - // as "nothing excluded". - var tasks *TasksSettings - if settings.Tasks != nil { - excluded := make([]string, len(settings.Tasks.ExcludedFolders)) - copy(excluded, settings.Tasks.ExcludedFolders) - tasks = &TasksSettings{ExcludedFolders: excluded} - } - // The same rule for the two later pointer fields: a caller reading the - // value SetSettings hands back must see what was written, not nil. - var typstPreambles *TypstPreambleSettings - if settings.TypstPreambles != nil { - typstPreambles = &TypstPreambleSettings{Folder: settings.TypstPreambles.Folder} - } - var harper *HarperSettings - if settings.Harper != nil { - words := make([]string, len(settings.Harper.Words)) - copy(words, settings.Harper.Words) - ignored := make([]string, len(settings.Harper.IgnoredLints)) - copy(ignored, settings.Harper.IgnoredLints) - harper = &HarperSettings{Words: words, IgnoredLints: ignored} - } - dailyLegacyPatterns := make([]DateNotePatternSettings, len(settings.DailyNotes.LegacyPatterns)) - copy(dailyLegacyPatterns, settings.DailyNotes.LegacyPatterns) - weeklyLegacyPatterns := make([]DateNotePatternSettings, len(settings.WeeklyNotes.LegacyPatterns)) - copy(weeklyLegacyPatterns, settings.WeeklyNotes.LegacyPatterns) - monthlyLegacyPatterns := make([]DateNotePatternSettings, len(settings.MonthlyNotes.LegacyPatterns)) - copy(monthlyLegacyPatterns, settings.MonthlyNotes.LegacyPatterns) - return VaultSettings{ - PrimaryNotesLocation: settings.PrimaryNotesLocation, - DailyNotes: DailyNotesSettings{ - Enabled: settings.DailyNotes.Enabled, - Directory: settings.DailyNotes.Directory, - TitlePattern: settings.DailyNotes.TitlePattern, - Locale: settings.DailyNotes.Locale, - LegacyPatterns: dailyLegacyPatterns, - TemplateID: settings.DailyNotes.TemplateID, - TasksDueOnNoteDate: settings.DailyNotes.TasksDueOnNoteDate, - RolloverUnfinishedTasks: settings.DailyNotes.RolloverUnfinishedTasks, - }, - WeeklyNotes: WeeklyNotesSettings{ - Enabled: settings.WeeklyNotes.Enabled, - Directory: settings.WeeklyNotes.Directory, - TitlePattern: settings.WeeklyNotes.TitlePattern, - Locale: settings.WeeklyNotes.Locale, - LegacyPatterns: weeklyLegacyPatterns, - TemplateID: settings.WeeklyNotes.TemplateID, - }, - MonthlyNotes: MonthlyNotesSettings{ - Enabled: settings.MonthlyNotes.Enabled, - Directory: settings.MonthlyNotes.Directory, - TitlePattern: settings.MonthlyNotes.TitlePattern, - Locale: settings.MonthlyNotes.Locale, - LegacyPatterns: monthlyLegacyPatterns, - TemplateID: settings.MonthlyNotes.TemplateID, - }, - DrawingsLocation: settings.DrawingsLocation, - DatabasesLocation: settings.DatabasesLocation, - TasksLocation: settings.TasksLocation, - FolderIcons: folderIcons, - FolderColors: folderColors, - Favorites: favorites, - SystemFolderPaths: systemFolderPaths, - Tasks: tasks, - TypstPreambles: typstPreambles, - Harper: harper, - } -} - -func normalizeDailyNotesDirectory(value string) string { - trimmed := strings.Trim(value, "/") - if trimmed == "" { - return DefaultDailyNotesDirectory - } - return trimmed -} - -func normalizeDailyNoteTitlePattern(value string) string { - trimmed := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(value)) - if trimmed == "" { - return DefaultDailyNoteTitlePattern - } - return trimmed -} - -func normalizeDailyNoteLocale(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return DefaultDailyNoteLocale - } - return trimmed -} - -func normalizeWeeklyNotesDirectory(value string) string { - trimmed := strings.Trim(value, "/") - if trimmed == "" { - return DefaultWeeklyNotesDirectory - } - return trimmed -} - -func normalizeWeeklyNoteTitlePattern(value string) string { - trimmed := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(value)) - if trimmed == "" { - return DefaultWeeklyNoteTitlePattern - } - return trimmed -} - -func normalizeWeeklyNoteLocale(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return DefaultWeeklyNoteLocale - } - return trimmed -} - -func normalizeMonthlyNotesDirectory(value string) string { - trimmed := strings.Trim(value, "/") - if trimmed == "" { - return DefaultMonthlyNotesDirectory - } - return trimmed -} - -func normalizeMonthlyNoteTitlePattern(value string) string { - trimmed := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(value)) - if trimmed == "" { - return DefaultMonthlyNoteTitlePattern - } - return trimmed -} - -func normalizeMonthlyNoteLocale(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return DefaultMonthlyNoteLocale - } - return trimmed -} - -func normalizeDailyNoteLegacyPatterns(value []DateNotePatternSettings) []DateNotePatternSettings { - out := []DateNotePatternSettings{} - seen := map[string]bool{} - for _, pattern := range value { - next := DateNotePatternSettings{ - Directory: normalizeDailyNotesDirectory(pattern.Directory), - TitlePattern: normalizeDailyNoteTitlePattern(pattern.TitlePattern), - Locale: normalizeDailyNoteLocale(pattern.Locale), - } - key := next.Directory + "\x00" + next.TitlePattern + "\x00" + next.Locale - if seen[key] { - continue - } - seen[key] = true - out = append(out, next) - } - return out -} - -func normalizeWeeklyNoteLegacyPatterns(value []DateNotePatternSettings) []DateNotePatternSettings { - out := []DateNotePatternSettings{} - seen := map[string]bool{} - for _, pattern := range value { - next := DateNotePatternSettings{ - Directory: normalizeWeeklyNotesDirectory(pattern.Directory), - TitlePattern: normalizeWeeklyNoteTitlePattern(pattern.TitlePattern), - Locale: normalizeWeeklyNoteLocale(pattern.Locale), - } - key := next.Directory + "\x00" + next.TitlePattern + "\x00" + next.Locale - if seen[key] { - continue - } - seen[key] = true - out = append(out, next) - } - return out -} - -func normalizeMonthlyNoteLegacyPatterns(value []DateNotePatternSettings) []DateNotePatternSettings { - out := []DateNotePatternSettings{} - seen := map[string]bool{} - for _, pattern := range value { - next := DateNotePatternSettings{ - Directory: normalizeMonthlyNotesDirectory(pattern.Directory), - TitlePattern: normalizeMonthlyNoteTitlePattern(pattern.TitlePattern), - Locale: normalizeMonthlyNoteLocale(pattern.Locale), - } - key := next.Directory + "\x00" + next.TitlePattern + "\x00" + next.Locale - if seen[key] { - continue - } - seen[key] = true - out = append(out, next) - } - return out -} - -func normalizePrimaryNotesLocation(value PrimaryNotesLocation) PrimaryNotesLocation { - if value == PrimaryNotesRoot { - return PrimaryNotesRoot - } - return PrimaryNotesInbox -} - -func normalizeVaultSettings(value VaultSettings, fallbackPrimary PrimaryNotesLocation) VaultSettings { - folderIcons := map[string]FolderIconID{} - for key, value := range value.FolderIcons { - if key == "" { - continue - } - if _, ok := validFolderIconIDs[value]; !ok { - continue - } - folderIcons[key] = value - } - folderColors := map[string]FolderColorID{} - for key, value := range value.FolderColors { - if key == "" { - continue - } - if _, ok := validFolderColorIDs[value]; !ok { - continue - } - folderColors[key] = value - } - return VaultSettings{ - PrimaryNotesLocation: normalizePrimaryNotesLocation(func() PrimaryNotesLocation { - if value.PrimaryNotesLocation == "" { - return fallbackPrimary - } - return value.PrimaryNotesLocation - }()), - DailyNotes: DailyNotesSettings{ - Enabled: value.DailyNotes.Enabled, - Directory: normalizeDailyNotesDirectory(value.DailyNotes.Directory), - TitlePattern: normalizeDailyNoteTitlePattern(value.DailyNotes.TitlePattern), - Locale: normalizeDailyNoteLocale(value.DailyNotes.Locale), - LegacyPatterns: normalizeDailyNoteLegacyPatterns(value.DailyNotes.LegacyPatterns), - TemplateID: value.DailyNotes.TemplateID, - TasksDueOnNoteDate: value.DailyNotes.TasksDueOnNoteDate, - RolloverUnfinishedTasks: value.DailyNotes.RolloverUnfinishedTasks, - }, - WeeklyNotes: WeeklyNotesSettings{ - Enabled: value.WeeklyNotes.Enabled, - Directory: normalizeWeeklyNotesDirectory(value.WeeklyNotes.Directory), - TitlePattern: normalizeWeeklyNoteTitlePattern(value.WeeklyNotes.TitlePattern), - Locale: normalizeWeeklyNoteLocale(value.WeeklyNotes.Locale), - LegacyPatterns: normalizeWeeklyNoteLegacyPatterns(value.WeeklyNotes.LegacyPatterns), - TemplateID: value.WeeklyNotes.TemplateID, - }, - MonthlyNotes: MonthlyNotesSettings{ - Enabled: value.MonthlyNotes.Enabled, - Directory: normalizeMonthlyNotesDirectory(value.MonthlyNotes.Directory), - TitlePattern: normalizeMonthlyNoteTitlePattern(value.MonthlyNotes.TitlePattern), - Locale: normalizeMonthlyNoteLocale(value.MonthlyNotes.Locale), - LegacyPatterns: normalizeMonthlyNoteLegacyPatterns(value.MonthlyNotes.LegacyPatterns), - TemplateID: value.MonthlyNotes.TemplateID, - }, - DrawingsLocation: normalizeFileLocation(value.DrawingsLocation), - DatabasesLocation: normalizeFileLocation(value.DatabasesLocation), - TasksLocation: normalizeFileLocation(value.TasksLocation), - FolderIcons: folderIcons, - FolderColors: folderColors, - Favorites: normalizeFavorites(value.Favorites), - SystemFolderPaths: normalizeSystemFolderPaths(value.SystemFolderPaths), - Tasks: normalizeTasksSettings(value.Tasks), - TypstPreambles: normalizeTypstPreambleSettings(value.TypstPreambles), - Harper: normalizeHarperSettings(value.Harper), - } -} - -// normalizeHarperSettings mirrors shared-domain's normalizeHarperVaultState: -// trimmed, de-duplicated words; ignored lints kept only when they are digit -// strings; nil when nothing is left so vault.json carries no empty block. -func normalizeHarperSettings(value *HarperSettings) *HarperSettings { - if value == nil { - return nil - } - words := uniqueTrimmedStrings(value.Words, func(string) bool { return true }) - ignored := uniqueTrimmedStrings(value.IgnoredLints, isDigitString) - if len(words) == 0 && len(ignored) == 0 { - return nil - } - return &HarperSettings{Words: words, IgnoredLints: ignored} -} - -func uniqueTrimmedStrings(values []string, keep func(string) bool) []string { - seen := map[string]struct{}{} - result := []string{} - for _, entry := range values { - cleaned := strings.TrimSpace(entry) - if cleaned == "" || !keep(cleaned) { - continue - } - if _, dup := seen[cleaned]; dup { - continue - } - seen[cleaned] = struct{}{} - result = append(result, cleaned) - } - return result -} - -func isDigitString(value string) bool { - for _, r := range value { - if r < '0' || r > '9' { - return false - } - } - return true -} - -// normalizeFileLocation mirrors app-core's normalizeFileLocation: validate the -// mode (unknown → primary) and, for folder mode, trim whitespace and slashes so -// the stored value round-trips cleanly (#446). -func normalizeFileLocation(value FileLocationSetting) FileLocationSetting { - switch value.Mode { - case FileLocationActiveNote: - return FileLocationSetting{Mode: FileLocationActiveNote} - case FileLocationFolder: - folder := strings.Trim(strings.TrimSpace(value.Folder), "/") - return FileLocationSetting{Mode: FileLocationFolder, Folder: folder} - default: - return FileLocationSetting{Mode: FileLocationPrimary} - } -} - -func normalizeFavorites(value []string) []string { - out := []string{} - seen := map[string]struct{}{} - for _, entry := range value { - if entry == "" { - continue - } - if _, ok := seen[entry]; ok { - continue - } - seen[entry] = struct{}{} - out = append(out, entry) - } - return out -} - -func folderIconKey(folder NoteFolder, subpath string) string { - return fmt.Sprintf("%s:%s", folder, subpath) -} - -func rewriteFolderIconsForRename( - folderIcons map[string]FolderIconID, - folder NoteFolder, - oldSubpath string, - newSubpath string, -) map[string]FolderIconID { - next := map[string]FolderIconID{} - exactKey := folderIconKey(folder, oldSubpath) - prefix := exactKey + "/" - for key, value := range folderIcons { - switch { - case key == exactKey: - next[folderIconKey(folder, newSubpath)] = value - case strings.HasPrefix(key, prefix): - next[folderIconKey(folder, newSubpath)+key[len(exactKey):]] = value - default: - next[key] = value - } - } - return next -} - -// rewriteFolderColorsForRename keeps a folder's accent color attached to it (and -// its descendants) when the folder is renamed, mirroring the icon rewrite. (#379) -func rewriteFolderColorsForRename( - folderColors map[string]FolderColorID, - folder NoteFolder, - oldSubpath string, - newSubpath string, -) map[string]FolderColorID { - next := map[string]FolderColorID{} - exactKey := folderIconKey(folder, oldSubpath) - prefix := exactKey + "/" - for key, value := range folderColors { - switch { - case key == exactKey: - next[folderIconKey(folder, newSubpath)] = value - case strings.HasPrefix(key, prefix): - next[folderIconKey(folder, newSubpath)+key[len(exactKey):]] = value - default: - next[key] = value - } - } - return next -} - -func removeFolderIcons( - folderIcons map[string]FolderIconID, - folder NoteFolder, - subpath string, -) map[string]FolderIconID { - next := map[string]FolderIconID{} - exactKey := folderIconKey(folder, subpath) - prefix := exactKey + "/" - for key, value := range folderIcons { - if key == exactKey || strings.HasPrefix(key, prefix) { - continue - } - next[key] = value - } - return next -} - -// removeFolderColors drops the deleted folder's (and its descendants') accent -// colors, mirroring removeFolderIcons. (#379) -func removeFolderColors( - folderColors map[string]FolderColorID, - folder NoteFolder, - subpath string, -) map[string]FolderColorID { - next := map[string]FolderColorID{} - exactKey := folderIconKey(folder, subpath) - prefix := exactKey + "/" - for key, value := range folderColors { - if key == exactKey || strings.HasPrefix(key, prefix) { - continue - } - next[key] = value - } - return next -} - -func duplicateFolderIcons( - folderIcons map[string]FolderIconID, - folder NoteFolder, - sourceSubpath string, - targetSubpath string, -) map[string]FolderIconID { - next := map[string]FolderIconID{} - for key, value := range folderIcons { - next[key] = value - } - exactKey := folderIconKey(folder, sourceSubpath) - prefix := exactKey + "/" - for key, value := range folderIcons { - switch { - case key == exactKey: - next[folderIconKey(folder, targetSubpath)] = value - case strings.HasPrefix(key, prefix): - next[folderIconKey(folder, targetSubpath)+key[len(exactKey):]] = value - } - } - return next -} - -// duplicateFolderColors copies the source folder's (and descendants') accent -// colors onto the duplicated folder, mirroring duplicateFolderIcons. (#379) -func duplicateFolderColors( - folderColors map[string]FolderColorID, - folder NoteFolder, - sourceSubpath string, - targetSubpath string, -) map[string]FolderColorID { - next := map[string]FolderColorID{} - for key, value := range folderColors { - next[key] = value - } - exactKey := folderIconKey(folder, sourceSubpath) - prefix := exactKey + "/" - for key, value := range folderColors { - switch { - case key == exactKey: - next[folderIconKey(folder, targetSubpath)] = value - case strings.HasPrefix(key, prefix): - next[folderIconKey(folder, targetSubpath)+key[len(exactKey):]] = value - } - } - return next -} - -func (v *Vault) settingsPath() string { - return filepath.Join(v.root, internalVaultDir, vaultSettingsFile) -} - -func (v *Vault) commentsRoot() string { - return filepath.Join(v.root, internalVaultDir, noteCommentsDir) -} - -func (v *Vault) commentsPath(rel string) (string, error) { - return SafeJoin(v.commentsRoot(), filepath.ToSlash(rel)+noteCommentsSuffix) -} - -func (v *Vault) inferPrimaryNotesLocation() PrimaryNotesLocation { - entries, err := os.ReadDir(v.root) - if err != nil { - return PrimaryNotesInbox - } - for _, entry := range entries { - name := entry.Name() - if strings.HasPrefix(name, ".") { - continue - } - if _, reserved := reservedRootNames[name]; reserved { - continue - } - if entry.IsDir() || strings.EqualFold(filepath.Ext(name), ".md") || isExcalidrawName(name) { - return PrimaryNotesRoot - } - } - return PrimaryNotesInbox -} - -func (v *Vault) vaultLooksEmpty() bool { - entries, err := os.ReadDir(v.root) - if err != nil { - return true - } - for _, entry := range entries { - name := entry.Name() - if strings.HasPrefix(name, ".") || name == internalVaultDir { - continue - } - return false - } - return true -} - -// GetSettings reads the vault settings, reparsing vault.json only when the file -// on disk has actually changed. Every folderRoot() call consults the settings, -// so a single client refresh asked for them about five times and each ask meant -// a ReadDir of the root plus a read and a JSON parse of vault.json. -// -// The cache is keyed on the file's mtime and size, and one open handle serves -// both the stat and the read so the key always describes the bytes that were -// parsed. A stat of the path followed by a read of the path would leave a -// window where the file is swapped in between (js/file-system-race). This -// mirrors getVaultSettings in apps/desktop/src/main/vault.ts. -func (v *Vault) GetSettings() (VaultSettings, error) { - file, err := os.Open(v.settingsPath()) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - v.invalidateSettingsCache() - return normalizeVaultSettings(VaultSettings{}, v.inferPrimaryNotesLocation()), nil - } - return VaultSettings{}, err - } - defer file.Close() - info, err := file.Stat() - if err != nil { - return VaultSettings{}, err - } - if cached, ok := v.cachedSettings(info); ok { - return cached, nil - } - raw, err := io.ReadAll(file) - if err != nil { - return VaultSettings{}, err - } - var settings VaultSettings - if err := json.Unmarshal(raw, &settings); err != nil { - return VaultSettings{}, err - } - normalized := normalizeVaultSettings(settings, v.inferPrimaryNotesLocation()) - v.storeSettingsCache(normalized, info) - return cloneSettings(normalized), nil -} - -// cachedSettings returns the cached settings when they were parsed from a -// vault.json with this exact mtime and size. The copy is defensive: callers -// mutate what GetSettings hands them. -func (v *Vault) cachedSettings(info os.FileInfo) (VaultSettings, bool) { - v.settingsMu.Lock() - defer v.settingsMu.Unlock() - cached := v.settingsCache - if cached == nil || cached.size != info.Size() || !cached.modTime.Equal(info.ModTime()) { - return VaultSettings{}, false - } - return cloneSettings(cached.settings), true -} - -func (v *Vault) storeSettingsCache(settings VaultSettings, info os.FileInfo) { - v.settingsMu.Lock() - defer v.settingsMu.Unlock() - v.settingsCache = &cachedVaultSettings{ - settings: cloneSettings(settings), - modTime: info.ModTime(), - size: info.Size(), - } -} - -func (v *Vault) invalidateSettingsCache() { - v.settingsMu.Lock() - defer v.settingsMu.Unlock() - v.settingsCache = nil -} - -func (v *Vault) SetSettings(next VaultSettings) (VaultSettings, error) { - fallbackPrimary := v.inferPrimaryNotesLocation() - normalized := normalizeVaultSettings(next, fallbackPrimary) - // Read before the write, while the cache still answers with the old value: - // a note's tags depend on the preamble folder (#562), so cached metas - // describe the previous setting the moment it moves. - previousPreambleFolder := v.typstPreambleFolder() - if err := os.MkdirAll(filepath.Dir(v.settingsPath()), v.dirMode); err != nil { - return VaultSettings{}, err - } - data, err := json.MarshalIndent(normalized, "", " ") - if err != nil { - return VaultSettings{}, err - } - if err := os.WriteFile(v.settingsPath(), data, v.fileMode); err != nil { - return VaultSettings{}, err - } - // The next read re-parses rather than trusting a same-tick mtime. - v.invalidateSettingsCache() - if normalized.PrimaryNotesLocation == PrimaryNotesInbox { - inbox := filepath.Join(v.root, resolveFolderPath(FolderInbox, normalized.SystemFolderPaths)) - if err := os.MkdirAll(inbox, v.dirMode); err != nil { - return VaultSettings{}, err - } - } - v.invalidateTextSearchCache() - if resolveTypstPreambleFolder(normalized) != previousPreambleFolder { - v.invalidateNoteMetaCache() - } - return cloneSettings(normalized), nil -} - -func (v *Vault) primaryNotesRoot() (string, error) { - settings, err := v.GetSettings() - if err != nil { - return "", err - } - if settings.PrimaryNotesLocation == PrimaryNotesRoot { - return v.root, nil - } - // Resolve through the override, the same as EnsureLayout: hardcoding - // `inbox` here made every inbox read and write miss a remapped inbox while - // the layout pass dutifully created the remapped directory. (#115) - return filepath.Join(v.root, resolveFolderPath(FolderInbox, settings.SystemFolderPaths)), nil -} - -func (v *Vault) folderRoot(folder NoteFolder) (string, error) { - if folder == FolderInbox { - return v.primaryNotesRoot() - } - settings, err := v.GetSettings() - if err != nil { - return "", err - } - p := resolveFolderPath(folder, settings.SystemFolderPaths) - return filepath.Join(v.root, p), nil -} - -// EnsureLayout creates the four top-level folders and seeds a welcome -// note if the vault is empty. Matches src/main/vault.ts ensureVaultLayout. -func (v *Vault) EnsureLayout() error { - wasEmpty := v.vaultLooksEmpty() - settings, err := v.GetSettings() - if err != nil { - return err - } - for _, f := range AllFolders { - if f == FolderInbox && settings.PrimaryNotesLocation == PrimaryNotesRoot { - continue - } - p := resolveFolderPath(f, settings.SystemFolderPaths) - if err := os.MkdirAll(filepath.Join(v.root, p), v.dirMode); err != nil { - return err - } - } - if wasEmpty { - welcomeDir, err := v.primaryNotesRoot() - if err != nil { - return err - } - if err := os.MkdirAll(welcomeDir, v.dirMode); err != nil { - return err - } - welcome := filepath.Join(welcomeDir, "Welcome.md") - if _, err := os.Stat(welcome); errors.Is(err, os.ErrNotExist) { - _ = os.WriteFile(welcome, []byte(welcomeNote), v.fileMode) - } - } - return nil -} - -// --- Listing --- - -func validCachedNoteMeta(meta NoteMeta, path string) bool { - if meta.Path != path || meta.Title == "" || !IsValidFolder(meta.Folder) { - return false - } - if meta.Tags == nil || meta.Wikilinks == nil { - return false - } - return true -} - -func (v *Vault) hydratePersistedNoteMetaCache() { - v.metaCacheMu.Lock() - if v.metaCacheLoad { - v.metaCacheMu.Unlock() - return - } - v.metaCacheLoad = true - v.metaCacheMu.Unlock() - - raw, err := os.ReadFile(v.noteMetaCachePath()) - if err != nil { - return - } - var persisted persistedNoteMetaCache - if err := json.Unmarshal(raw, &persisted); err != nil || persisted.Version != noteMetaCacheVersion { - return - } - - entries := map[string]noteMetaCacheEntry{} - for _, entry := range persisted.Entries { - if entry.Path == "" || !validCachedNoteMeta(entry.Meta, entry.Path) { - continue - } - abs, err := SafeJoin(v.root, entry.Path) - if err != nil { - continue - } - entries[abs] = noteMetaCacheEntry{ - mtimeMs: entry.MtimeMs, - size: entry.Size, - meta: entry.Meta, - } - } - if len(entries) == 0 { - return - } - - v.metaCacheMu.Lock() - for key, entry := range entries { - v.metaCache[key] = entry - } - v.metaCacheMu.Unlock() -} - -func (v *Vault) persistNoteMetaCacheSnapshot(metas []NoteMeta) { - if os.Getenv("ZEN_PERF_DISABLE_PERSISTED_META_CACHE") == "1" { - return - } - v.metaCacheMu.Lock() - generation := v.metaCacheGen - v.metaCacheMu.Unlock() - if len(metas) == 0 { - return - } - metas = append([]NoteMeta(nil), metas...) - - go func(metas []NoteMeta, generation uint64) { - time.Sleep(time.Second) - - entries := make([]persistedNoteMetaEntry, 0, len(metas)) - v.metaCacheMu.Lock() - if v.metaCacheGen != generation { - v.metaCacheMu.Unlock() - return - } - for _, meta := range metas { - abs, err := SafeJoin(v.root, meta.Path) - if err != nil { - continue - } - cached, ok := v.metaCache[abs] - if !ok { - continue - } - metaCopy := cached.meta - metaCopy.SiblingOrder = meta.SiblingOrder - entries = append(entries, persistedNoteMetaEntry{ - Path: meta.Path, - MtimeMs: cached.mtimeMs, - Size: cached.size, - Meta: metaCopy, - }) - } - v.metaCacheMu.Unlock() - if len(entries) == 0 { - return - } - - target := v.noteMetaCachePath() - temp := fmt.Sprintf("%s.%d.%d.tmp", target, os.Getpid(), time.Now().UnixNano()) - if err := os.MkdirAll(filepath.Dir(target), v.dirMode); err != nil { - return - } - data, err := json.Marshal(persistedNoteMetaCache{ - Version: noteMetaCacheVersion, - Entries: entries, - }) - if err != nil { - return - } - data = append(data, '\n') - if err := os.WriteFile(temp, data, v.fileMode); err != nil { - return - } - v.metaCacheMu.Lock() - stillCurrent := v.metaCacheGen == generation - v.metaCacheMu.Unlock() - if !stillCurrent { - _ = os.Remove(temp) - return - } - if err := os.Rename(temp, target); err != nil { - _ = os.Remove(temp) - } - }(metas, generation) -} - -// ListNotes walks every top-level folder and returns metadata for each -// note. Sibling order is the directory-listing order per folder, which -// matches the TS version's behaviour for non-sorted filesystems. -// isSkippableWalkErr reports whether a directory-walk error should skip the -// offending entry and keep scanning, rather than aborting the whole vault scan. -// Covers entries that vanished mid-scan and, importantly for self-hosted -// servers, entries the process lacks permission to read (e.g. a vault copied in -// with root-owned files while the container runs as a non-root user). (#159) -func isSkippableWalkErr(err error) bool { - return errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) -} - -func (v *Vault) ListNotes() ([]NoteMeta, error) { - v.mu.RLock() - defer v.mu.RUnlock() - v.hydratePersistedNoteMetaCache() - - settings, err := v.GetSettings() - if err != nil { - return nil, err - } - hiddenRootNames := hiddenPrimaryRootNames(settings) - - type noteFile struct { - folder NoteFolder - path string - } - - files := []noteFile{} - for _, folder := range AllFolders { - folderRoot, err := v.folderRoot(folder) - if err != nil { - return nil, err - } - isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) - err = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { - if err != nil { - if isSkippableWalkErr(err) { - return nil - } - return err - } - if d.IsDir() { - if strings.HasPrefix(d.Name(), ".") && path != folderRoot { - return filepath.SkipDir - } - // A `.base` folder is NOT skipped here. Its record pages are - // real notes: the user opens them, writes in them, and links to - // them, and the desktop lists them exactly like any other note. - // Skipping the folder made every wikilink into a database resolve - // to nothing on a remote vault while working locally (#527). Only - // `.md` files are collected below, so the database's own data.csv - // and schema.json still never surface as notes. ListFolders and - // ListAssets DO skip it, deliberately: the internals are not loose - // folders or attachments, and the renderer draws the database - // itself rather than its directory. - if isPrimaryRoot && path != folderRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return filepath.SkipDir - } - } - } - return nil - } - if isPrimaryRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return filepath.SkipDir - } - } - } - if !strings.EqualFold(filepath.Ext(d.Name()), ".md") && !isExcalidrawName(d.Name()) { - return nil - } - files = append(files, noteFile{folder: folder, path: path}) - return nil - }) - if err != nil { - return nil, err - } - } - - results := make([]NoteMeta, len(files)) - ok := make([]bool, len(files)) - limit := noteMetaReadLimit - if len(files) < limit { - limit = len(files) - } - sem := make(chan struct{}, limit) - // Resolved once for the whole scan, not once per note: this loop reads a - // meta per file across a pool of goroutines, and resolving inside would - // open and stat vault.json (and take settingsMu) for every one of them. - preambleFolder := v.typstPreambleFolder() - var wg sync.WaitGroup - for index, file := range files { - wg.Add(1) - go func(index int, file noteFile) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - meta, err := v.readMetaWithPreambleFolder(file.folder, file.path, preambleFolder) - if err != nil { - return // skip unreadable files silently - } - results[index] = meta - ok[index] = true - }(index, file) - } - wg.Wait() - - out := make([]NoteMeta, 0, len(files)) - for index, meta := range results { - if ok[index] { - out = append(out, meta) - } - } - - // sibling order per directory (by appearance in out for that dir) - assignSiblingOrder(out, func(m NoteMeta) string { - return filepath.Dir(m.Path) - }, func(m *NoteMeta, i int) { m.SiblingOrder = i }) - v.persistNoteMetaCacheSnapshot(out) - return out, nil -} - -func assignSiblingOrder[T any](list []T, key func(T) string, set func(*T, int)) { - counts := map[string]int{} - for i := range list { - k := key(list[i]) - set(&list[i], counts[k]) - counts[k]++ - } -} - -// ListFolders enumerates every non-root subdirectory under each top-level folder. -func (v *Vault) ListFolders() ([]FolderEntry, error) { - v.mu.RLock() - defer v.mu.RUnlock() - settings, err := v.GetSettings() - if err != nil { - return nil, err - } - hiddenRootNames := hiddenPrimaryRootNames(settings) - out := []FolderEntry{} - for _, folder := range AllFolders { - folderRoot, err := v.folderRoot(folder) - if err != nil { - return nil, err - } - isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) - err = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { - if err != nil { - if isSkippableWalkErr(err) { - return nil - } - return err - } - if !d.IsDir() { - return nil - } - if path == folderRoot { - return nil - } - if strings.HasPrefix(d.Name(), ".") { - return filepath.SkipDir - } - if isPrimaryRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return filepath.SkipDir - } - } - } - rel, err := filepath.Rel(folderRoot, path) - if err != nil { - return nil - } - out = append(out, FolderEntry{ - Folder: folder, - Subpath: filepath.ToSlash(rel), - }) - // A `.base` database folder is listed (the renderer shows it as - // a database) but its internals are not exposed as folders. - if isFormDirName(d.Name()) { - return filepath.SkipDir - } - return nil - }) - if err != nil { - return nil, err - } - } - sort.SliceStable(out, func(i, j int) bool { - if out[i].Folder != out[j].Folder { - return out[i].Folder < out[j].Folder - } - return out[i].Subpath < out[j].Subpath - }) - assignSiblingOrder(out, func(f FolderEntry) string { - parent := filepath.Dir(f.Subpath) - return string(f.Folder) + "/" + parent - }, func(f *FolderEntry, i int) { f.SiblingOrder = i }) - return out, nil -} - -// ListAssets walks the attachments directory. -func (v *Vault) ListAssets() ([]AssetMeta, error) { - v.mu.RLock() - defer v.mu.RUnlock() - out := []AssetMeta{} - var walk func(dir string) error - walk = func(dir string) error { - entries, err := os.ReadDir(dir) - if err != nil { - if isSkippableWalkErr(err) { - return nil - } - return err - } - for index, entry := range entries { - name := entry.Name() - if strings.HasPrefix(name, ".") || IsAtomicWriteTempPath(name) { - continue - } - full := filepath.Join(dir, name) - if entry.IsDir() { - if filepath.Clean(dir) == filepath.Clean(v.root) && name == internalVaultDir { - continue - } - if isFormDirName(name) { - continue // database folder — its data.csv/schema.json aren't assets - } - if err := walk(full); err != nil { - if isSkippableWalkErr(err) { - continue - } - return err - } - continue - } - if !entry.Type().IsRegular() || strings.EqualFold(filepath.Ext(name), ".md") || isExcalidrawName(name) { - continue - } - info, err := entry.Info() - if err != nil { - continue - } - rel, err := filepath.Rel(v.root, full) - if err != nil { - continue - } - out = append(out, AssetMeta{ - Path: filepath.ToSlash(rel), - Name: name, - Kind: kindForExt(strings.ToLower(filepath.Ext(name))), - SiblingOrder: index, - Size: info.Size(), - UpdatedAt: info.ModTime().UnixMilli(), - }) - } - return nil - } - if err := walk(v.root); err != nil { - return nil, err - } - sort.SliceStable(out, func(i, j int) bool { - return out[i].UpdatedAt > out[j].UpdatedAt - }) - return out, nil -} - -func (v *Vault) HasAssetsDir() bool { - v.mu.RLock() - defer v.mu.RUnlock() - for _, dir := range append([]string{AssetsDir}, legacyAttachmentsDirs...) { - info, err := os.Stat(filepath.Join(v.root, dir)) - if err == nil && info.IsDir() { - return true - } - } - return false -} - -func kindForExt(ext string) string { - switch ext { - case ".apng", ".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp": - return "image" - case ".pdf": - return "pdf" - case ".aac", ".flac", ".m4a", ".mp3", ".ogg", ".wav": - return "audio" - case ".m4v", ".mov", ".mp4", ".ogv", ".webm": - return "video" - } - return "file" -} - -// --- Read / Write --- - -// buildNoteMeta assembles NoteMeta for a note-like file. Excalidraw drawings -// store JSON, not Markdown, so their tags/wikilinks/excerpt are skipped — a hex -// color like "#1971c2" in the scene must not register as a #tag. -func buildNoteMeta(relPosix, title string, folder NoteFolder, info os.FileInfo, bodyStr, preambleFolder string) NoteMeta { - meta := NoteMeta{ - Path: relPosix, - Title: title, - Folder: folder, - CreatedAt: info.ModTime().UnixMilli(), - UpdatedAt: info.ModTime().UnixMilli(), - Size: info.Size(), - Tags: []string{}, - Wikilinks: []string{}, - } - if isExcalidrawName(relPosix) { - return meta - } - meta.Wikilinks = ExtractWikilinks(bodyStr) - meta.HasAttachments = BodyHasLocalAsset(bodyStr) - meta.Excerpt = BuildExcerpt(bodyStr) - // A Typst preamble holds Typst source, not prose: `#let vec(x) = bold(x)` - // and the `#var` references in its formulas are variables, so indexing them - // filled the tag list with `let` and every variable name (#562). Tags only: - // the note keeps its excerpt, wikilinks and searchability. - if isTypstPreamblePath(relPosix, preambleFolder) { - return meta - } - meta.Tags = ExtractTags(bodyStr) - return meta -} - -func (v *Vault) readMeta(folder NoteFolder, abs string) (NoteMeta, error) { - return v.readMetaWithPreambleFolder(folder, abs, v.typstPreambleFolder()) -} - -// readMetaWithPreambleFolder is readMeta with the preamble folder already -// resolved, for callers reading many notes at once. -func (v *Vault) readMetaWithPreambleFolder( - folder NoteFolder, - abs, preambleFolder string, -) (NoteMeta, error) { - info, err := os.Stat(abs) - if err != nil { - return NoteMeta{}, err - } - rel, err := filepath.Rel(v.root, abs) - if err != nil { - return NoteMeta{}, err - } - relPosix := filepath.ToSlash(rel) - statMtimeMs := mtimeMs(info) - v.metaCacheMu.Lock() - cached, ok := v.metaCache[abs] - if ok && - sameMtimeMs(cached.mtimeMs, statMtimeMs) && - cached.size == info.Size() && - cached.meta.Path == relPosix && - cached.meta.Folder == folder { - meta := cached.meta - v.metaCacheMu.Unlock() - return meta, nil - } - v.metaCacheMu.Unlock() - - body, err := os.ReadFile(abs) - if err != nil { - return NoteMeta{}, err - } - bodyStr := string(body) - - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - - meta := buildNoteMeta(relPosix, title, folder, info, bodyStr, preambleFolder) - v.metaCacheMu.Lock() - v.metaCache[abs] = noteMetaCacheEntry{ - mtimeMs: statMtimeMs, - size: info.Size(), - meta: meta, - } - v.metaCacheMu.Unlock() - return meta, nil -} - -func (v *Vault) ReadNote(rel string) (NoteContent, error) { - v.mu.RLock() - defer v.mu.RUnlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteContent{}, err - } - info, err := os.Stat(abs) - if err != nil { - return NoteContent{}, err - } - // The stat above already knows the answer, so say it here instead of - // reading and then guessing from the platform's errno. - if info.IsDir() { - return NoteContent{}, ErrIsDirectory - } - body, err := os.ReadFile(abs) - if err != nil { - return NoteContent{}, err - } - folder, _ := v.folderOf(abs) - bodyStr := string(body) - rel = filepath.ToSlash(rel) - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - meta := buildNoteMeta(rel, title, folder, info, bodyStr, v.typstPreambleFolder()) - return NoteContent{NoteMeta: meta, Body: bodyStr}, nil -} - -func (v *Vault) WriteNote(rel, body string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteMeta{}, err - } - if err := writeFileAtomic(abs, []byte(body), v.fileMode, v.dirMode); err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - folder, _ := v.folderOf(abs) - return v.readMeta(folder, abs) -} - -func newCommentID() string { - var b [16]byte - if _, err := rand.Read(b[:]); err == nil { - return hex.EncodeToString(b[:]) - } - return fmt.Sprintf("comment-%d", time.Now().UnixNano()) -} - -func normalizeComment(input NoteComment, notePath string) (NoteComment, bool) { - body := strings.TrimSpace(input.Body) - if body == "" { - return NoteComment{}, false - } - start := input.AnchorStart - if start < 0 { - start = 0 - } - end := input.AnchorEnd - if end < 0 { - end = start - } - if end < start { - start, end = end, start - } - anchorText := strings.Join(strings.Fields(input.AnchorText), " ") - if len(anchorText) > 500 { - anchorText = anchorText[:500] - } - now := time.Now().UnixMilli() - createdAt := input.CreatedAt - if createdAt <= 0 { - createdAt = now - } - updatedAt := input.UpdatedAt - if updatedAt <= 0 { - updatedAt = now - } - id := strings.TrimSpace(input.ID) - if id == "" { - id = newCommentID() - } - return NoteComment{ - ID: id, - NotePath: notePath, - AnchorStart: start, - AnchorEnd: end, - AnchorText: anchorText, - Body: body, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - ResolvedAt: input.ResolvedAt, - Author: normalizeCommentAuthor(input.Author), - ParentID: strings.TrimSpace(input.ParentID), - }, true -} - -// normalizeCommentAuthor collapses whitespace and caps the name, mirroring -// normalizeCommentAuthor in shared-domain/note-comments.ts. -func normalizeCommentAuthor(raw string) string { - author := strings.Join(strings.Fields(raw), " ") - if len(author) > 80 { - author = author[:80] - } - return author -} - -func normalizeComments(inputs []NoteComment, notePath string) []NoteComment { - out := make([]NoteComment, 0, len(inputs)) - seen := map[string]struct{}{} - for _, input := range inputs { - comment, ok := normalizeComment(input, notePath) - if !ok { - continue - } - if _, exists := seen[comment.ID]; exists { - continue - } - seen[comment.ID] = struct{}{} - out = append(out, comment) - } - sort.SliceStable(out, func(i, j int) bool { - if out[i].CreatedAt == out[j].CreatedAt { - return out[i].ID < out[j].ID - } - return out[i].CreatedAt < out[j].CreatedAt - }) - // A reply whose parent is gone (or is itself) stays as a comment of its - // own rather than vanishing from the thread view. - ids := make(map[string]struct{}, len(out)) - for _, comment := range out { - ids[comment.ID] = struct{}{} - } - for i := range out { - if out[i].ParentID == "" { - continue - } - if _, ok := ids[out[i].ParentID]; !ok || out[i].ParentID == out[i].ID { - out[i].ParentID = "" - } - } - return out -} - -func (v *Vault) readNoteCommentsLocked(rel string) ([]NoteComment, error) { - notePath := filepath.ToSlash(rel) - abs, err := v.commentsPath(notePath) - if err != nil { - return nil, err - } - raw, err := os.ReadFile(abs) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return []NoteComment{}, nil - } - return nil, err - } - var envelope struct { - Comments []NoteComment `json:"comments"` - } - if err := json.Unmarshal(raw, &envelope); err == nil && envelope.Comments != nil { - return normalizeComments(envelope.Comments, notePath), nil - } - var comments []NoteComment - if err := json.Unmarshal(raw, &comments); err != nil { - return []NoteComment{}, nil - } - return normalizeComments(comments, notePath), nil -} - -func (v *Vault) ReadNoteComments(rel string) ([]NoteComment, error) { - v.mu.RLock() - defer v.mu.RUnlock() - return v.readNoteCommentsLocked(rel) -} - -func (v *Vault) writeNoteCommentsLocked(rel string, comments []NoteComment) ([]NoteComment, error) { - notePath := filepath.ToSlash(rel) - normalized := normalizeComments(comments, notePath) - abs, err := v.commentsPath(notePath) - if err != nil { - return nil, err - } - if len(normalized) == 0 { - if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, err - } - return []NoteComment{}, nil - } - if err := os.MkdirAll(filepath.Dir(abs), v.dirMode); err != nil { - return nil, err - } - data, err := json.MarshalIndent(struct { - Version int `json:"version"` - Comments []NoteComment `json:"comments"` - }{Version: 1, Comments: normalized}, "", " ") - if err != nil { - return nil, err - } - if err := os.WriteFile(abs, data, v.fileMode); err != nil { - return nil, err - } - return normalized, nil -} - -func (v *Vault) WriteNoteComments(rel string, comments []NoteComment) ([]NoteComment, error) { - v.mu.Lock() - defer v.mu.Unlock() - return v.writeNoteCommentsLocked(rel, comments) -} - -func (v *Vault) removeNoteCommentsLocked(rel string) error { - abs, err := v.commentsPath(rel) - if err != nil { - return err - } - if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil -} - -func (v *Vault) moveNoteCommentsLocked(oldRel, nextRel string) error { - oldAbs, err := v.commentsPath(oldRel) - if err != nil { - return err - } - nextAbs, err := v.commentsPath(nextRel) - if err != nil { - return err - } - if oldAbs == nextAbs { - return nil - } - if _, err := os.Stat(oldAbs); err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err - } - if err := os.MkdirAll(filepath.Dir(nextAbs), v.dirMode); err != nil { - return err - } - if _, err := os.Stat(nextAbs); err == nil { - existing, err := v.readNoteCommentsLocked(nextRel) - if err != nil { - return err - } - moving, err := v.readNoteCommentsLocked(oldRel) - if err != nil { - return err - } - if _, err := v.writeNoteCommentsLocked(nextRel, append(existing, moving...)); err != nil { - return err - } - return os.Remove(oldAbs) - } else if !errors.Is(err, os.ErrNotExist) { - return err - } - return os.Rename(oldAbs, nextAbs) -} - -func (v *Vault) copyNoteCommentsLocked(sourceRel, nextRel string) error { - source, err := v.readNoteCommentsLocked(sourceRel) - if err != nil { - return err - } - if len(source) == 0 { - return nil - } - now := time.Now().UnixMilli() - copyComments := make([]NoteComment, 0, len(source)) - for _, comment := range source { - comment.ID = newCommentID() - comment.NotePath = filepath.ToSlash(nextRel) - comment.CreatedAt = now - comment.UpdatedAt = now - copyComments = append(copyComments, comment) - } - _, err = v.writeNoteCommentsLocked(nextRel, copyComments) - return err -} - -// folderOf classifies an absolute path by the folder it lives in, honoring the -// on-disk overrides: without them a note in a remapped trash directory came -// back tagged `inbox`, and a restore lost the subfolder it was trashed from. -// typstPreambleFolder resolves the vault's preamble folder (#562) the same way -// folderOf resolves the system-folder remap: through GetSettings, whose parse -// is cached against vault.json's identity. Falls back to the default when the -// settings cannot be read, so a note is never mistaken for a preamble. -func (v *Vault) typstPreambleFolder() string { - settings, err := v.GetSettings() - if err != nil { - return DefaultTypstPreambleFolder - } - return resolveTypstPreambleFolder(settings) -} - -func (v *Vault) folderOf(abs string) (NoteFolder, bool) { - rel, err := filepath.Rel(v.root, abs) - if err != nil { - return "", false - } - var paths map[string]string - if settings, err := v.GetSettings(); err == nil { - paths = settings.SystemFolderPaths - } - return FolderForRelativePathWithSettings(rel, paths) -} - -// --- Create / Rename / Delete --- - -func (v *Vault) CreateNote(folder NoteFolder, title, subpath string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - if !IsValidFolder(folder) { - return NoteMeta{}, fmt.Errorf("invalid folder: %s", folder) - } - if title == "" { - title = defaultTitle() - } - title = sanitizeFileStem(title) - dir, err := v.folderRoot(folder) - if err != nil { - return NoteMeta{}, err - } - if subpath != "" { - sub, err := SafeJoin(dir, subpath) - if err != nil { - return NoteMeta{}, err - } - dir = sub - } - if err := os.MkdirAll(dir, v.dirMode); err != nil { - return NoteMeta{}, err - } - abs := uniquePath(dir, title, ".md") - // Seed the same `# Title` body the desktop app writes (main vault.ts and - // the MCP vault-ops both do), from the FINAL on-disk stem so a deduped - // "Title 2" heads itself correctly. A remote vault otherwise creates - // blank notes where a local one has its title, which is most visible on - // daily notes, whose date heading is the whole point. - stem := strings.TrimSuffix(filepath.Base(abs), ".md") - if err := os.WriteFile(abs, fmt.Appendf(nil, "# %s\n\n", stem), v.fileMode); err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - return v.readMeta(folder, abs) -} - -// CreateExcalidraw writes a new empty `.excalidraw` drawing under folder/subpath -// and returns its meta. Mirrors CreateNote but seeds an empty Excalidraw scene. -func (v *Vault) CreateExcalidraw(folder NoteFolder, title, subpath string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - if !IsValidFolder(folder) { - return NoteMeta{}, fmt.Errorf("invalid folder: %s", folder) - } - if title == "" { - title = defaultTitle() - } - title = sanitizeFileStem(title) - dir, err := v.folderRoot(folder) - if err != nil { - return NoteMeta{}, err - } - if subpath != "" { - sub, err := SafeJoin(dir, subpath) - if err != nil { - return NoteMeta{}, err - } - dir = sub - } - if err := os.MkdirAll(dir, v.dirMode); err != nil { - return NoteMeta{}, err - } - abs := uniquePath(dir, title, excalidrawExt) - if err := os.WriteFile(abs, []byte(emptyExcalidrawJSON), v.fileMode); err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - return v.readMeta(folder, abs) -} - -func (v *Vault) RenameNote(rel, nextTitle string) (NoteMeta, error) { - // Snapshot the vault before the rename (ListNotes takes its own read lock) - // so inbound [[wikilinks]] still resolve to this note under its current name. - notesBefore, _ := v.ListNotes() - meta, err := v.renameNoteFile(rel, nextTitle) - if err != nil { - return NoteMeta{}, err - } - if meta.Path != rel { - // ReadNote / WriteNote take their own locks, so this runs after the - // rename's write lock has been released. - v.rewriteInboundWikilinks(notesBefore, rel, meta.Title) - } - return meta, nil -} - -func (v *Vault) renameNoteFile(rel, nextTitle string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteMeta{}, err - } - nextTitle = sanitizeFileStem(nextTitle) - if nextTitle == "" { - return NoteMeta{}, errors.New("empty title") - } - dir := filepath.Dir(abs) - newAbs := uniquePath(dir, nextTitle, noteExt(abs)) - if err := os.Rename(abs, newAbs); err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - folder, _ := v.folderOf(newAbs) - meta, err := v.readMeta(folder, newAbs) - if err != nil { - return NoteMeta{}, err - } - if err := v.moveNoteCommentsLocked(rel, meta.Path); err != nil { - return NoteMeta{}, err - } - return meta, nil -} - -// rewriteInboundWikilinks rewrites every note that linked to the renamed note's -// old name so it points to the new title. Only notes that actually link to it -// are read and rewritten. -func (v *Vault) rewriteInboundWikilinks(notesBefore []NoteMeta, oldPath, newTitle string) { - for _, n := range notesBefore { - if n.Path == oldPath || n.Folder == FolderTrash { - continue - } - linksToIt := false - for _, t := range n.Wikilinks { - if r, ok := wikiResolveTarget(notesBefore, t); ok && r.Path == oldPath { - linksToIt = true - break - } - } - if !linksToIt { - continue - } - content, err := v.ReadNote(n.Path) - if err != nil { - continue - } - body, changed := rewriteWikilinksForRename(content.Body, notesBefore, oldPath, newTitle) - if changed > 0 { - _, _ = v.WriteNote(n.Path, body) - } - } -} - -func (v *Vault) DeleteNote(rel string) error { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return err - } - if err := os.Remove(abs); err != nil { - return err - } - v.invalidateTextSearchCache() - return v.removeNoteCommentsLocked(rel) -} - -// --- Trash / Restore / Archive / Unarchive / Duplicate / Move --- - -func (v *Vault) MoveToTrash(rel string) (NoteMeta, error) { - return v.moveBetweenFolders(rel, FolderTrash) -} -func (v *Vault) RestoreFromTrash(rel string) (NoteMeta, error) { - return v.moveBetweenFolders(rel, FolderInbox) -} -func (v *Vault) ArchiveNote(rel string) (NoteMeta, error) { - return v.moveBetweenFolders(rel, FolderArchive) -} -func (v *Vault) UnarchiveNote(rel string) (NoteMeta, error) { - return v.moveBetweenFolders(rel, FolderInbox) -} - -// folderSubpathOf returns the note's directory relative to its top-level -// folder root ("" when it sits at the folder root). Carried along on -// archive/trash moves so the reverse move restores the subfolder. -// Mirrors folderSubpathOf in apps/desktop/src/main/vault.ts. -func (v *Vault) folderSubpathOf(abs string) string { - folder, ok := v.folderOf(abs) - if !ok { - return "" - } - sourceRoot, err := v.folderRoot(folder) - if err != nil { - return "" - } - relDir, err := filepath.Rel(sourceRoot, filepath.Dir(abs)) - if err != nil || relDir == "." || strings.HasPrefix(relDir, "..") || filepath.IsAbs(relDir) { - return "" - } - return relDir -} - -func (v *Vault) moveBetweenFolders(rel string, target NoteFolder) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteMeta{}, err - } - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - // Mirror the source subfolder in the destination so a round-trip - // (archive → unarchive, trash → restore) puts the note back where - // it came from instead of at the folder's top level. - subpath := v.folderSubpathOf(abs) - targetRoot, err := v.folderRoot(target) - if err != nil { - return NoteMeta{}, err - } - destDir := targetRoot - if subpath != "" { - destDir, err = SafeJoin(targetRoot, subpath) - if err != nil { - return NoteMeta{}, err - } - } - if err := os.MkdirAll(destDir, v.dirMode); err != nil { - return NoteMeta{}, err - } - newAbs := uniquePath(destDir, title, noteExt(abs)) - if err := os.Rename(abs, newAbs); err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - meta, err := v.readMeta(target, newAbs) - if err != nil { - return NoteMeta{}, err - } - if err := v.moveNoteCommentsLocked(rel, meta.Path); err != nil { - return NoteMeta{}, err - } - return meta, nil -} - -func (v *Vault) EmptyTrash() error { - v.mu.Lock() - defer v.mu.Unlock() - trashDir := filepath.Join(v.root, string(FolderTrash)) - entries, err := os.ReadDir(trashDir) - if err != nil { - return nil - } - for _, e := range entries { - _ = v.removeNoteCommentsLocked(filepath.ToSlash(filepath.Join(string(FolderTrash), e.Name()))) - _ = os.RemoveAll(filepath.Join(trashDir, e.Name())) - } - v.invalidateTextSearchCache() - return nil -} - -func (v *Vault) DuplicateNote(rel string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteMeta{}, err - } - folder, _ := v.folderOf(abs) - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) + " copy" - newAbs := uniquePath(filepath.Dir(abs), sanitizeFileStem(title), noteExt(abs)) - if err := copyFile(abs, newAbs, v.fileMode); err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - meta, err := v.readMeta(folder, newAbs) - if err != nil { - return NoteMeta{}, err - } - if err := v.copyNoteCommentsLocked(rel, meta.Path); err != nil { - return NoteMeta{}, err - } - return meta, nil -} - -func (v *Vault) MoveNote(rel string, target NoteFolder, targetSubpath string) (NoteMeta, error) { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return NoteMeta{}, err - } - if !IsValidFolder(target) { - return NoteMeta{}, fmt.Errorf("invalid folder: %s", target) - } - destDir, err := v.folderRoot(target) - if err != nil { - return NoteMeta{}, err - } - if targetSubpath != "" { - sub, err := SafeJoin(destDir, targetSubpath) - if err != nil { - return NoteMeta{}, err - } - destDir = sub - } - if err := os.MkdirAll(destDir, v.dirMode); err != nil { - return NoteMeta{}, err - } - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - newAbs := uniquePath(destDir, title, noteExt(abs)) - if err := os.Rename(abs, newAbs); err != nil { - return NoteMeta{}, err - } - v.invalidateTextSearchCache() - meta, err := v.readMeta(target, newAbs) - if err != nil { - return NoteMeta{}, err - } - if err := v.moveNoteCommentsLocked(rel, meta.Path); err != nil { - return NoteMeta{}, err - } - return meta, nil -} - -// --- Folders --- - -func (v *Vault) CreateFolder(folder NoteFolder, subpath string) error { - v.mu.Lock() - defer v.mu.Unlock() - if !IsValidFolder(folder) { - return fmt.Errorf("invalid folder: %s", folder) - } - base, err := v.folderRoot(folder) - if err != nil { - return err - } - abs, err := SafeJoin(base, subpath) - if err != nil { - return err - } - return os.MkdirAll(abs, v.dirMode) -} - -func (v *Vault) RenameFolder(folder NoteFolder, oldSub, newSub string) (string, error) { - v.mu.Lock() - defer v.mu.Unlock() - base, err := v.folderRoot(folder) - if err != nil { - return "", err - } - oldAbs, err := SafeJoin(base, oldSub) - if err != nil { - return "", err - } - newAbs, err := SafeJoin(base, newSub) - if err != nil { - return "", err - } - if err := os.MkdirAll(filepath.Dir(newAbs), v.dirMode); err != nil { - return "", err - } - if err := os.Rename(oldAbs, newAbs); err != nil { - return "", err - } - v.invalidateTextSearchCache() - settings, err := v.GetSettings() - if err != nil { - return "", err - } - _, err = v.SetSettings(VaultSettings{ - PrimaryNotesLocation: settings.PrimaryNotesLocation, - DailyNotes: settings.DailyNotes, - WeeklyNotes: settings.WeeklyNotes, - MonthlyNotes: settings.MonthlyNotes, - FolderIcons: rewriteFolderIconsForRename(settings.FolderIcons, folder, oldSub, newSub), - FolderColors: rewriteFolderColorsForRename(settings.FolderColors, folder, oldSub, newSub), - // Favorites are carried through verbatim; the client rewrites stale - // favorite keys after the rename and re-persists them. - Favorites: settings.Favorites, - }) - if err != nil { - return "", err - } - rel, _ := filepath.Rel(base, newAbs) - return filepath.ToSlash(rel), nil -} - -func (v *Vault) DeleteFolder(folder NoteFolder, subpath string) error { - v.mu.Lock() - defer v.mu.Unlock() - base, err := v.folderRoot(folder) - if err != nil { - return err - } - abs, err := SafeJoin(base, subpath) - if err != nil { - return err - } - if abs == base { - return errors.New("refusing to delete top-level folder") - } - if err := os.RemoveAll(abs); err != nil { - return err - } - v.invalidateTextSearchCache() - settings, err := v.GetSettings() - if err != nil { - return err - } - _, err = v.SetSettings(VaultSettings{ - PrimaryNotesLocation: settings.PrimaryNotesLocation, - DailyNotes: settings.DailyNotes, - WeeklyNotes: settings.WeeklyNotes, - MonthlyNotes: settings.MonthlyNotes, - FolderIcons: removeFolderIcons(settings.FolderIcons, folder, subpath), - FolderColors: removeFolderColors(settings.FolderColors, folder, subpath), - // Favorites are carried through verbatim; the client prunes the deleted - // folder's favorites and re-persists them. - Favorites: settings.Favorites, - }) - return err -} - -func (v *Vault) DuplicateFolder(folder NoteFolder, subpath string) (string, error) { - v.mu.Lock() - defer v.mu.Unlock() - base, err := v.folderRoot(folder) - if err != nil { - return "", err - } - src, err := SafeJoin(base, subpath) - if err != nil { - return "", err - } - parent := filepath.Dir(src) - baseName := filepath.Base(src) + " copy" - dst := uniqueDir(parent, baseName) - if err := copyDir(src, dst, v.fileMode, v.dirMode); err != nil { - return "", err - } - v.invalidateTextSearchCache() - settings, err := v.GetSettings() - if err != nil { - return "", err - } - rel, _ := filepath.Rel(base, dst) - relPath := filepath.ToSlash(rel) - _, err = v.SetSettings(VaultSettings{ - PrimaryNotesLocation: settings.PrimaryNotesLocation, - DailyNotes: settings.DailyNotes, - WeeklyNotes: settings.WeeklyNotes, - MonthlyNotes: settings.MonthlyNotes, - FolderIcons: duplicateFolderIcons(settings.FolderIcons, folder, subpath, relPath), - FolderColors: duplicateFolderColors(settings.FolderColors, folder, subpath, relPath), - // A duplicated folder isn't auto-favorited; carry existing favorites through. - Favorites: settings.Favorites, - }) - if err != nil { - return "", err - } - return relPath, nil -} - -// --- Tasks --- - -func (v *Vault) ScanTasks() ([]Task, error) { - return v.ScanTasksWith(ParseTasksOptions{}) -} - -// ScanTasksWith is ScanTasks honoring options: IncludeExcluded scans past -// both the vault-level excluded-folders list and the note-level frontmatter -// `tasks:` opt-out (#458). -func (v *Vault) ScanTasksWith(opts ParseTasksOptions) ([]Task, error) { - v.mu.RLock() - defer v.mu.RUnlock() - settings, err := v.GetSettings() - if err != nil { - return nil, err - } - excluded := tasksExcludedFolders(settings) - if opts.IncludeExcluded { - excluded = nil - } - hiddenRootNames := hiddenPrimaryRootNames(settings) - all := []Task{} - for _, folder := range []NoteFolder{FolderInbox, FolderQuick, FolderArchive} { - folderRoot, err := v.folderRoot(folder) - if err != nil { - return nil, err - } - isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) - _ = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - if strings.HasPrefix(d.Name(), ".") && path != folderRoot { - return filepath.SkipDir - } - // Not skipped, for the same reason as ListNotes: a database's - // record pages are notes, and a task written in one counts. The - // desktop scans tasks straight off its note list, so skipping here - // would have the two disagree about what a note is (#527). - if isPrimaryRoot && path != folderRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return filepath.SkipDir - } - } - } - return nil - } - if isPrimaryRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return nil - } - } - } - if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { - return nil - } - body, err := os.ReadFile(path) - if err != nil { - return nil - } - rel, _ := filepath.Rel(v.root, path) - relPosix := filepath.ToSlash(rel) - if isPathExcludedFromTasks(relPosix, excluded) { - return nil - } - title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - tasks := ParseTasksWith(relPosix, title, folder, string(body), opts) - all = append(all, tasks...) - return nil - }) - } - return all, nil -} - -func (v *Vault) ScanTasksForPath(rel string) ([]Task, error) { - return v.ScanTasksForPathWith(rel, ParseTasksOptions{}) -} - -// ScanTasksForPathWith is ScanTasksForPath honoring options. IncludeExcluded -// scans past the excluded-folders list and the frontmatter `tasks:` opt-out -// (never the trash gate): the remote task-toggle flow re-parses through here, -// and an explicitly-named task id is an explicit ask. -func (v *Vault) ScanTasksForPathWith(rel string, opts ParseTasksOptions) ([]Task, error) { - v.mu.RLock() - defer v.mu.RUnlock() - abs, err := SafeJoin(v.root, rel) - if err != nil { - return nil, err - } - body, err := os.ReadFile(abs) - if err != nil { - return nil, err - } - // Same gates as the full scan, or a single-note rescan would resurrect - // tasks the walker skips: trashed and unclassifiable notes contribute - // nothing (mirrors the desktop's LIVE_FOLDERS check), and neither do notes - // under an excluded folder (#458). The caller uses the empty result to - // drop stale rows. - folder, ok := v.folderOf(abs) - if !ok || folder == FolderTrash { - return []Task{}, nil - } - relPosix := filepath.ToSlash(rel) - if !opts.IncludeExcluded { - settings, err := v.GetSettings() - if err != nil { - return nil, err - } - if isPathExcludedFromTasks(relPosix, tasksExcludedFolders(settings)) { - return []Task{}, nil - } - } - title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - return ParseTasksWith(relPosix, title, folder, string(body), opts), nil -} - -// --- Text search --- - -func (v *Vault) SearchCapabilities() TextSearchCapabilities { - return TextSearchCapabilities{Ripgrep: false, Fzf: false} -} - -func (v *Vault) textSearchFilesLocked() (uint64, []textSearchFile, error) { - h := fnv.New64a() - files := []textSearchFile{} - settings, err := v.GetSettings() - if err != nil { - return 0, nil, err - } - hiddenRootNames := hiddenPrimaryRootNames(settings) - for _, folder := range []NoteFolder{FolderInbox, FolderQuick, FolderArchive} { - folderRoot, err := v.folderRoot(folder) - if err != nil { - return 0, nil, err - } - cleanFolderRoot := filepath.Clean(folderRoot) - isPrimaryRoot := folder == FolderInbox && cleanFolderRoot == filepath.Clean(v.root) - fmt.Fprintf(h, "folder\x00%s\x00%s\x00%t\x00", folder, filepath.ToSlash(cleanFolderRoot), isPrimaryRoot) - _ = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - if strings.HasPrefix(d.Name(), ".") && path != folderRoot { - return filepath.SkipDir - } - if isPrimaryRoot && path != folderRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == cleanFolderRoot { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return filepath.SkipDir - } - } - } - return nil - } - if isPrimaryRoot { - parent := filepath.Dir(path) - if filepath.Clean(parent) == cleanFolderRoot { - if shouldHidePrimaryRootName(d.Name(), hiddenRootNames) { - return nil - } - } - } - if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { - return nil - } - info, err := d.Info() - if err != nil { - return nil - } - rel, _ := filepath.Rel(v.root, path) - relPosix := filepath.ToSlash(rel) - title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - modNano := info.ModTime().UnixNano() - size := info.Size() - fmt.Fprintf(h, "file\x00%s\x00%s\x00%d\x00%d\x00", folder, relPosix, size, modNano) - files = append(files, textSearchFile{ - abs: path, - relPosix: relPosix, - title: title, - folder: folder, - }) - return nil - }) - } - return h.Sum64(), files, nil -} - -func (v *Vault) textSearchCandidatesLocked() ([]textSearchCandidate, error) { - signature, files, err := v.textSearchFilesLocked() - if err != nil { - return nil, err - } - - v.searchCacheMu.Lock() - if v.searchCache != nil && v.searchCache.signature == signature { - candidates := v.searchCache.candidates - v.searchCacheMu.Unlock() - return candidates, nil - } - v.searchCacheMu.Unlock() - - groups := make([][]textSearchCandidate, len(files)) - limit := noteMetaReadLimit - if len(files) < limit { - limit = len(files) - } - sem := make(chan struct{}, limit) - var wg sync.WaitGroup - for index, file := range files { - wg.Add(1) - go func(index int, file textSearchFile) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - body, err := os.ReadFile(file.abs) - if err != nil { - return - } - lines := strings.Split(string(body), "\n") - offset := 0 - candidates := make([]textSearchCandidate, 0, len(lines)) - for i, line := range lines { - collapsed := wsCollapseRe.ReplaceAllString(line, " ") - collapsed = strings.TrimSpace(collapsed) - if len(collapsed) > 220 { - collapsed = collapsed[:220] - } - candidates = append(candidates, textSearchCandidate{ - match: TextSearchMatch{ - Path: file.relPosix, - Title: file.title, - Folder: file.folder, - LineNumber: i + 1, - Offset: offset, - LineText: collapsed, - }, - lineLower: strings.ToLower(line), - }) - offset += len(line) + 1 - } - groups[index] = candidates - }(index, file) - } - wg.Wait() - - candidates := []textSearchCandidate{} - for _, group := range groups { - candidates = append(candidates, group...) - } - - v.searchCacheMu.Lock() - if v.searchCache != nil && v.searchCache.signature == signature { - candidates = v.searchCache.candidates - } else { - v.searchCache = &textSearchCache{ - signature: signature, - candidates: candidates, - } - } - v.searchCacheMu.Unlock() - - return candidates, nil -} - -func (v *Vault) SearchText(query string) ([]TextSearchMatch, error) { - v.mu.RLock() - defer v.mu.RUnlock() - query = strings.TrimSpace(query) - if query == "" { - return []TextSearchMatch{}, nil - } - needle := strings.ToLower(query) - candidates, err := v.textSearchCandidatesLocked() - if err != nil { - return nil, err - } - out := []TextSearchMatch{} - for _, candidate := range candidates { - if !strings.Contains(candidate.lineLower, needle) { - continue - } - out = append(out, candidate.match) - if len(out) >= 200 { - break - } - } - return out, nil -} - -// --- Assets upload + raw serving --- - -// ImportAsset writes raw bytes into the unified assets/ folder and returns -// the vault-relative markdown snippet to embed. The destination -// mirrors the desktop importFiles/importPastedImage (#377): uploads used to -// land at the vault root, which in Vault Root mode dumped them right next to -// the notes. -// notePath is no longer read: the link is vault-relative now, so where the note -// lives does not change what gets written. It stays in the signature because -// the HTTP handler and clients still send it. -func (v *Vault) ImportAsset(notePath, filename string, body io.Reader) (ImportedAsset, error) { - _ = notePath - v.mu.Lock() - defer v.mu.Unlock() - assetsAbs := filepath.Join(v.root, AssetsDir) - if err := os.MkdirAll(assetsAbs, v.dirMode); err != nil { - return ImportedAsset{}, err - } - safeName := sanitizeFileName(filename) - if safeName == "" { - safeName = "file" - } - ext := filepath.Ext(safeName) - stem := strings.TrimSuffix(safeName, ext) - abs := uniquePath(assetsAbs, stem, ext) - f, err := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, v.fileMode) - if err != nil { - return ImportedAsset{}, err - } - cleanupPartial := func() { - _ = f.Close() - _ = os.Remove(abs) - } - limited := io.LimitReader(body, v.maxAssetBytes+1) - written, err := io.Copy(f, limited) - if err != nil { - cleanupPartial() - return ImportedAsset{}, err - } - if written > v.maxAssetBytes { - cleanupPartial() - return ImportedAsset{}, ErrAssetTooLarge - } - if err := f.Close(); err != nil { - _ = os.Remove(abs) - return ImportedAsset{}, err - } - relFromRoot, err := filepath.Rel(v.root, abs) - if err != nil { - return ImportedAsset{}, err - } - rel := filepath.ToSlash(relFromRoot) - kind := kindForExt(strings.ToLower(filepath.Ext(abs))) - markdown := makeAssetMarkdown(rel, kind, filepath.Base(abs)) - return ImportedAsset{ - Name: filepath.Base(abs), - Path: rel, - Markdown: markdown, - Kind: kind, - }, nil -} - -func (v *Vault) AssetAbsPath(rel string) (string, error) { - v.mu.RLock() - defer v.mu.RUnlock() - return SafeJoin(v.root, rel) -} - -// RenameAsset renames an asset file in place (same directory), mirroring the -// desktop renameAsset. It refuses internal files and markdown notes, and -// handles a case-only rename on case-insensitive filesystems. (#379) Every -// note that referenced the asset is then rewritten to its new name, the way -// RenameNote handles inbound wikilinks. (#785) -func (v *Vault) RenameAsset(rel, nextName string) (AssetMeta, error) { - // Snapshot before the move (both listings take their own read locks) so - // references still resolve to the asset under its current name; they are - // rewritten after the rename's write lock has been released. - assetsBefore, _ := v.ListAssets() - notesBefore, _ := v.ListNotes() - meta, oldRel, err := v.renameAssetFile(rel, nextName) - if err != nil { - return AssetMeta{}, err - } - if meta.Path != oldRel { - v.rewriteAssetReferences(notesBefore, assetsBefore, oldRel, meta.Path) - } - return meta, nil -} - -// renameAssetFile is the locked file move behind RenameAsset. It returns the -// new meta and the asset's vault-relative path before the move. -func (v *Vault) renameAssetFile(rel, nextName string) (AssetMeta, string, error) { - v.mu.Lock() - defer v.mu.Unlock() - srcAbs, err := v.assertAssetFile(rel) - if err != nil { - return AssetMeta{}, "", err - } - before, err := v.assetMetaForAbs(srcAbs) - if err != nil { - return AssetMeta{}, "", err - } - cleanName, err := cleanAssetFilename(nextName) - if err != nil { - return AssetMeta{}, "", err - } - destAbs := filepath.Join(filepath.Dir(srcAbs), cleanName) - if destAbs != srcAbs { - if dstInfo, statErr := os.Stat(destAbs); statErr == nil { - // Something is already at the destination. Allow it only when it is - // literally the same file (case-only rename on a case-insensitive - // filesystem), routing through a temp name; otherwise it collides. - srcInfo, srcErr := os.Stat(srcAbs) - if srcErr != nil { - return AssetMeta{}, "", srcErr - } - if !os.SameFile(dstInfo, srcInfo) { - return AssetMeta{}, "", fmt.Errorf("an asset named %q already exists in this folder", cleanName) - } - tmp := srcAbs + ".zenrename.tmp" - if err := os.Rename(srcAbs, tmp); err != nil { - return AssetMeta{}, "", err - } - if err := os.Rename(tmp, destAbs); err != nil { - return AssetMeta{}, "", err - } - } else if !errors.Is(statErr, os.ErrNotExist) { - return AssetMeta{}, "", statErr - } else if err := os.Rename(srcAbs, destAbs); err != nil { - return AssetMeta{}, "", err - } - } - meta, err := v.assetMetaForAbs(destAbs) - return meta, before.Path, err -} - -// MoveAsset moves an asset file into targetDir (vault-relative; empty means the -// unified assets/ folder), mirroring the desktop moveAsset. The filename is made -// unique in the destination. (#379) Every note that referenced the asset is -// then re-targeted to its new location, like RenameAsset. (#785) -func (v *Vault) MoveAsset(rel, targetDir string) (AssetMeta, error) { - assetsBefore, _ := v.ListAssets() - notesBefore, _ := v.ListNotes() - meta, oldRel, err := v.moveAssetFile(rel, targetDir) - if err != nil { - return AssetMeta{}, err - } - if meta.Path != oldRel { - v.rewriteAssetReferences(notesBefore, assetsBefore, oldRel, meta.Path) - } - return meta, nil -} - -// moveAssetFile is the locked file move behind MoveAsset. It returns the new -// meta and the asset's vault-relative path before the move. -func (v *Vault) moveAssetFile(rel, targetDir string) (AssetMeta, string, error) { - v.mu.Lock() - defer v.mu.Unlock() - srcAbs, err := v.assertAssetFile(rel) - if err != nil { - return AssetMeta{}, "", err - } - before, err := v.assetMetaForAbs(srcAbs) - if err != nil { - return AssetMeta{}, "", err - } - destDir, err := v.cleanAssetTargetDir(targetDir) - if err != nil { - return AssetMeta{}, "", err - } - if err := os.MkdirAll(destDir, v.dirMode); err != nil { - return AssetMeta{}, "", err - } - if filepath.Clean(destDir) == filepath.Clean(filepath.Dir(srcAbs)) { - return before, before.Path, nil - } - name := filepath.Base(srcAbs) - ext := filepath.Ext(name) - stem := strings.TrimSuffix(name, ext) - destAbs := uniquePath(destDir, stem, ext) - if err := os.Rename(srcAbs, destAbs); err != nil { - return AssetMeta{}, "", err - } - meta, err := v.assetMetaForAbs(destAbs) - return meta, before.Path, err -} - -// assertAssetFile validates rel points at an existing, editable asset file and -// returns its safe absolute path. Assumes the caller holds v.mu. -func (v *Vault) assertAssetFile(rel string) (string, error) { - trimmed := strings.Trim(strings.TrimSpace(filepath.ToSlash(rel)), "/") - if trimmed == "" { - return "", errors.New("asset path is required") - } - for _, part := range strings.Split(trimmed, "/") { - if part == internalVaultDir { - return "", errors.New("cannot modify internal ZenNotes files") - } - } - if strings.EqualFold(filepath.Ext(trimmed), ".md") { - return "", errors.New("use note actions to modify markdown notes") - } - abs, err := SafeJoin(v.root, trimmed) - if err != nil { - return "", err - } - info, err := os.Stat(abs) - if err != nil { - return "", err - } - if info.IsDir() { - return "", errors.New("asset path is not a file") - } - return abs, nil -} - -// cleanAssetTargetDir resolves a vault-relative destination directory for a -// move. Empty resolves to the unified assets/ folder. Assumes caller holds v.mu. -func (v *Vault) cleanAssetTargetDir(targetDir string) (string, error) { - normalized := strings.Trim(strings.TrimSpace(filepath.ToSlash(targetDir)), "/") - if normalized == "" { - return SafeJoin(v.root, AssetsDir) - } - for _, part := range strings.Split(normalized, "/") { - if part == internalVaultDir { - return "", errors.New("cannot move assets into internal ZenNotes files") - } - } - return SafeJoin(v.root, normalized) -} - -func (v *Vault) assetMetaForAbs(abs string) (AssetMeta, error) { - info, err := os.Stat(abs) - if err != nil { - return AssetMeta{}, err - } - rel, err := filepath.Rel(v.root, abs) - if err != nil { - return AssetMeta{}, err - } - name := filepath.Base(abs) - return AssetMeta{ - Path: filepath.ToSlash(rel), - Name: name, - Kind: kindForExt(strings.ToLower(filepath.Ext(name))), - SiblingOrder: 0, - Size: info.Size(), - UpdatedAt: info.ModTime().UnixMilli(), - }, nil -} - -func cleanAssetFilename(name string) (string, error) { - raw := strings.TrimSpace(name) - if strings.ContainsAny(raw, "/\\") { - return "", errors.New("use only a file name") - } - trimmed := filepath.Base(raw) - if trimmed == "" || trimmed == "." || trimmed == ".." { - return "", errors.New("asset name is required") - } - if strings.EqualFold(filepath.Ext(trimmed), ".md") { - return "", errors.New("use note actions for markdown notes") - } - return trimmed, nil -} - -// makeAssetMarkdown mirrors the desktop markdownForImportedAsset: everything is -// linked by VAULT-relative path, an image as a wikilink and anything else as a -// markdown link, which is the single form every client now writes. The link -// used to be relative to the note, so it broke as soon as the note moved to -// another depth (nothing rewrites relative asset paths on move). -func makeAssetMarkdown(vaultRelPath, kind, name string) string { - if kind == "image" { - return "![[" + vaultRelPath + "]]" - } - dest := "<" + strings.ReplaceAll(vaultRelPath, ">", "%3E") + ">" - return "[" + name + "](" + dest + ")" -} - -// --- Misc helpers --- - -var forbiddenFilenameChars = []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} - -func sanitizeFileStem(title string) string { - t := title - for _, c := range forbiddenFilenameChars { - t = strings.ReplaceAll(t, c, "") - } - t = strings.TrimSpace(t) - if t == "" { - t = defaultTitle() - } - return t -} - -func sanitizeFileName(name string) string { - leaf := filepath.Base(name) - safe := strings.Map(func(r rune) rune { - if r < 0x20 || strings.ContainsRune("\\/:%*?\"<>|[]#^", r) { - return '-' - } - return r - }, leaf) - safe = strings.Join(strings.Fields(safe), " ") - if safe == "." || safe == ".." { - return "" - } - return safe -} - -func defaultTitle() string { - return "Untitled-" + time.Now().Format("2006-01-02-150405") -} - -func uniquePath(dir, stem, ext string) string { - candidate := filepath.Join(dir, stem+ext) - if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { - return candidate - } - for i := 2; ; i++ { - candidate = filepath.Join(dir, fmt.Sprintf("%s %d%s", stem, i, ext)) - if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { - return candidate - } - } -} - -func uniqueDir(parent, base string) string { - candidate := filepath.Join(parent, base) - if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { - return candidate - } - for i := 2; ; i++ { - candidate = filepath.Join(parent, fmt.Sprintf("%s %d", base, i)) - if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { - return candidate - } - } -} - -func copyFile(src, dst string, mode fs.FileMode) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) - if err != nil { - return err - } - defer out.Close() - _, err = io.Copy(out, in) - return err -} - -func copyDir(src, dst string, fileMode, dirMode fs.FileMode) error { - return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - info, err := os.Lstat(path) - if err != nil { - return err - } - if info.Mode()&os.ModeSymlink != 0 { - return ErrPathEscape - } - rel, err := filepath.Rel(src, path) - if err != nil { - return err - } - target := filepath.Join(dst, rel) - if info.IsDir() { - return os.MkdirAll(target, dirMode) - } - if !info.Mode().IsRegular() { - return fmt.Errorf("unsupported file type in folder copy: %s", path) - } - return copyFile(path, target, fileMode) - }) -} - -const welcomeNote = `# Welcome to ZenNotes - -ZenNotes keeps your notes as plain markdown files. Press ` + "`?`" + ` to see the -keybinding cheat sheet, or start typing to begin. - -- Notes live in ` + "`inbox/`" + `, ` + "`quick/`" + `, ` + "`archive/`" + `, and ` + "`trash/`" + `. -- Every word you write stays on disk, under your control. -- Vim motions are on by default. -` diff --git a/apps/server/internal/vault/vault_test.go b/apps/server/internal/vault/vault_test.go deleted file mode 100644 index 0dc1e7cf..00000000 --- a/apps/server/internal/vault/vault_test.go +++ /dev/null @@ -1,1327 +0,0 @@ -package vault - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" -) - -func TestVaultDefaultModesAreTight(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("posix file modes") - } - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("hello.md", "hi"); err != nil { - t.Fatal(err) - } - - info, err := os.Stat(filepath.Join(v.Root(), "hello.md")) - if err != nil { - t.Fatal(err) - } - if perm := info.Mode().Perm(); perm != 0o600 { - t.Fatalf("note perm = %o, want 0600", perm) - } - - // Note files live under inbox/, but the directory was created during - // EnsureLayout. Inspect the inbox dir to verify dirMode applied. - dirInfo, err := os.Stat(filepath.Join(v.Root(), "inbox")) - if err != nil { - t.Fatal(err) - } - if perm := dirInfo.Mode().Perm(); perm != 0o700 { - t.Fatalf("inbox dir perm = %o, want 0700", perm) - } -} - -func TestVaultModeOverride(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("posix file modes") - } - root := t.TempDir() - v, err := New(root, Options{FileMode: 0o644, DirMode: 0o755}) - if err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("hello.md", "hi"); err != nil { - t.Fatal(err) - } - info, err := os.Stat(filepath.Join(v.Root(), "hello.md")) - if err != nil { - t.Fatal(err) - } - if perm := info.Mode().Perm(); perm != 0o644 { - t.Fatalf("override perm = %o, want 0644", perm) - } -} - -func TestImportAssetEnforcesMaxBytes(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{MaxAssetBytes: 16}) - if err != nil { - t.Fatal(err) - } - big := bytes.Repeat([]byte("a"), 17) - _, err = v.ImportAsset("note.md", "x.bin", bytes.NewReader(big)) - if !errors.Is(err, ErrAssetTooLarge) { - t.Fatalf("expected ErrAssetTooLarge, got %v", err) - } - // Partial file should be removed from the assets/ destination. - for _, dir := range []string{v.Root(), filepath.Join(v.Root(), AssetsDir)} { - entries, _ := os.ReadDir(dir) - for _, e := range entries { - if strings.HasSuffix(e.Name(), ".bin") { - t.Fatalf("partial asset %q should be cleaned up", e.Name()) - } - } - } -} - -func TestImportAssetWithinLimit(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{MaxAssetBytes: 32}) - if err != nil { - t.Fatal(err) - } - body := bytes.Repeat([]byte("a"), 16) - asset, err := v.ImportAsset("note.md", "x.bin", bytes.NewReader(body)) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - // Uploads land in the unified assets/ folder, matching the desktop (#377). - if asset.Path != AssetsDir+"/"+asset.Name { - t.Fatalf("asset path = %q, want it under %s/", asset.Path, AssetsDir) - } - abs := filepath.Join(v.Root(), filepath.FromSlash(asset.Path)) - got, err := os.ReadFile(abs) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, body) { - t.Fatalf("written bytes differ from input") - } - // A non-image also uses a vault-relative markdown link into assets/. - if asset.Markdown == "" || !strings.Contains(asset.Markdown, "assets/x.bin") { - t.Fatalf("markdown = %q, want a link into assets/", asset.Markdown) - } -} - -// An image embeds by vault-relative wikilink whatever folder the note is in, -// matching the desktop and both mobile apps, so moving the note cannot break -// the link (nothing rewrites relative asset paths on move). -func TestImportAssetEmbedsImagesByVaultRelativeWikilink(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - png := []byte{0x89, 'P', 'N', 'G'} - for _, notePath := range []string{"Note.md", "inbox/Note.md", "inbox/deep/Note.md"} { - asset, err := v.ImportAsset(notePath, "Photo.png", bytes.NewReader(png)) - if err != nil { - t.Fatalf("%s: %v", notePath, err) - } - want := "![[" + asset.Path + "]]" - if asset.Markdown != want { - t.Fatalf("%s: markdown = %q, want %q", notePath, asset.Markdown, want) - } - if strings.Contains(asset.Markdown, "../") { - t.Fatalf("%s: markdown = %q, must not be note-relative", notePath, asset.Markdown) - } - } -} - -func TestImportAssetScrubsNamesThatBreakWikilinks(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - asset, err := v.ImportAsset("Note.md", "Photo%5D [v2] #3.png", bytes.NewReader([]byte{0x89, 'P', 'N', 'G'})) - if err != nil { - t.Fatal(err) - } - if asset.Path != "assets/Photo-5D -v2- -3.png" { - t.Fatalf("asset path = %q, want a wikilink-safe filename", asset.Path) - } - if asset.Markdown != "![[assets/Photo-5D -v2- -3.png]]" { - t.Fatalf("markdown = %q, want a valid wikilink embed", asset.Markdown) - } -} - -func TestImportAssetReportsAtBoundary(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{MaxAssetBytes: 8}) - if err != nil { - t.Fatal(err) - } - body := bytes.Repeat([]byte("a"), 8) - if _, err := v.ImportAsset("note.md", "x.bin", bytes.NewReader(body)); err != nil { - t.Fatalf("8/8 bytes should succeed, got %v", err) - } - // 9-byte body must be rejected even though only one byte over. - body9 := bytes.Repeat([]byte("a"), 9) - if _, err := v.ImportAsset("note.md", "y.bin", bytes.NewReader(body9)); !errors.Is(err, ErrAssetTooLarge) { - t.Fatalf("9/8 should reject with ErrAssetTooLarge, got %v", err) - } -} - -func TestNoteCommentsFollowRenameDuplicateAndDelete(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - meta, err := v.WriteNote("inbox/Alpha.md", "hello world") - if err != nil { - t.Fatal(err) - } - comments, err := v.WriteNoteComments(meta.Path, []NoteComment{{ - AnchorStart: 0, - AnchorEnd: 5, - AnchorText: "hello", - Body: "Tighten this claim.", - }}) - if err != nil { - t.Fatal(err) - } - if len(comments) != 1 || comments[0].ID == "" { - t.Fatalf("comment was not normalized: %#v", comments) - } - - renamed, err := v.RenameNote(meta.Path, "Beta") - if err != nil { - t.Fatal(err) - } - oldComments, err := v.ReadNoteComments(meta.Path) - if err != nil { - t.Fatal(err) - } - if len(oldComments) != 0 { - t.Fatalf("old sidecar still has comments: %#v", oldComments) - } - renamedComments, err := v.ReadNoteComments(renamed.Path) - if err != nil { - t.Fatal(err) - } - if len(renamedComments) != 1 || renamedComments[0].NotePath != renamed.Path { - t.Fatalf("comments did not follow rename: %#v", renamedComments) - } - - duplicated, err := v.DuplicateNote(renamed.Path) - if err != nil { - t.Fatal(err) - } - duplicatedComments, err := v.ReadNoteComments(duplicated.Path) - if err != nil { - t.Fatal(err) - } - if len(duplicatedComments) != 1 || duplicatedComments[0].NotePath != duplicated.Path { - t.Fatalf("comments did not copy to duplicate: %#v", duplicatedComments) - } - if duplicatedComments[0].ID == renamedComments[0].ID { - t.Fatalf("duplicated note should get independent comment ids") - } - - if err := v.DeleteNote(renamed.Path); err != nil { - t.Fatal(err) - } - deletedComments, err := v.ReadNoteComments(renamed.Path) - if err != nil { - t.Fatal(err) - } - if len(deletedComments) != 0 { - t.Fatalf("comments should be removed with deleted note: %#v", deletedComments) - } -} - -func TestReadNoteRefusesSymlinkOutsideVault(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - outside := t.TempDir() - secret := filepath.Join(outside, "secret.txt") - if err := os.WriteFile(secret, []byte("classified"), 0o600); err != nil { - t.Fatal(err) - } - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - link := filepath.Join(v.Root(), "evil.md") - if err := os.Symlink(secret, link); err != nil { - t.Fatal(err) - } - - if _, err := v.ReadNote("evil.md"); !errors.Is(err, ErrPathEscape) { - t.Fatalf("expected ErrPathEscape via ReadNote, got %v", err) - } -} - -// A `.base` database is a directory, and a client that treats one as a note -// asks to read it as a file. The answer has to be the same everywhere: the -// errno differs by platform (EISDIR on Unix, ERROR_INVALID_FUNCTION on -// Windows), which is why the HTTP layer once said 400 on macOS and Linux and -// 500 on Windows for the identical request. ReadNote classifies it from its -// own stat, so this test means the same thing on every runner. -func TestReadNoteRejectsADirectoryOnEveryPlatform(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(v.Root(), "inbox", "Db.base"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(v.Root(), "inbox", "Real.md"), []byte("# Real\n"), 0o600); err != nil { - t.Fatal(err) - } - - if _, err := v.ReadNote("inbox/Db.base"); !errors.Is(err, ErrIsDirectory) { - t.Fatalf("reading a directory: got %v, want ErrIsDirectory", err) - } - // The classification must not swallow the two answers around it: a real - // note still reads, and a missing file inside that directory is still - // absent rather than "is a directory". - if _, err := v.ReadNote("inbox/Real.md"); err != nil { - t.Fatalf("reading a note: %v", err) - } - if _, err := v.ReadNote("inbox/Db.base/data.csv"); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("missing file: got %v, want os.ErrNotExist", err) - } -} - -func TestWriteNoteRefusesSymlinkOutsideVault(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - outside := t.TempDir() - target := filepath.Join(outside, "victim.txt") - if err := os.WriteFile(target, []byte("original"), 0o600); err != nil { - t.Fatal(err) - } - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - link := filepath.Join(v.Root(), "evil.md") - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - - if _, err := v.WriteNote("evil.md", "tampered"); !errors.Is(err, ErrPathEscape) { - t.Fatalf("expected ErrPathEscape via WriteNote, got %v", err) - } - // The target file outside the vault must not be touched. - got, err := os.ReadFile(target) - if err != nil { - t.Fatal(err) - } - if string(got) != "original" { - t.Fatalf("file outside vault was modified: %q", got) - } -} - -func TestDuplicateFolderRefusesNestedSymlink(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on windows") - } - root := t.TempDir() - outside := t.TempDir() - secret := filepath.Join(outside, "secret.md") - if err := os.WriteFile(secret, []byte("classified"), 0o600); err != nil { - t.Fatal(err) - } - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - source := filepath.Join(v.Root(), string(FolderInbox), "source") - if err := os.MkdirAll(source, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(source, "safe.md"), []byte("safe"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.Symlink(secret, filepath.Join(source, "leak.md")); err != nil { - t.Fatal(err) - } - - if _, err := v.DuplicateFolder(FolderInbox, "source"); !errors.Is(err, ErrPathEscape) { - t.Fatalf("expected ErrPathEscape duplicating folder with symlink, got %v", err) - } - if _, err := os.Stat(filepath.Join(v.Root(), string(FolderInbox), "source copy", "leak.md")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("symlink target should not be copied into duplicated folder, stat err=%v", err) - } -} - -func TestSearchTextRefreshesAfterExternalChange(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - meta, err := v.WriteNote("inbox/Search.md", "alpha only\n") - if err != nil { - t.Fatal(err) - } - - matches, err := v.SearchText("alpha") - if err != nil { - t.Fatal(err) - } - if !textSearchMatchesPath(matches, meta.Path) { - t.Fatalf("initial search did not find %s: %#v", meta.Path, matches) - } - - abs := filepath.Join(v.Root(), filepath.FromSlash(meta.Path)) - if err := os.WriteFile(abs, []byte("beta only\n"), 0o600); err != nil { - t.Fatal(err) - } - future := time.Now().Add(2 * time.Second) - if err := os.Chtimes(abs, future, future); err != nil { - t.Fatal(err) - } - - matches, err = v.SearchText("alpha") - if err != nil { - t.Fatal(err) - } - if textSearchMatchesPath(matches, meta.Path) { - t.Fatalf("stale search result still found %s: %#v", meta.Path, matches) - } - - matches, err = v.SearchText("beta") - if err != nil { - t.Fatal(err) - } - if !textSearchMatchesPath(matches, meta.Path) { - t.Fatalf("refreshed search did not find %s: %#v", meta.Path, matches) - } -} - -func TestListNotesUsesMatchingPersistedMetadata(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - rel := filepath.ToSlash(filepath.Join(string(FolderInbox), "cached.md")) - abs := filepath.Join(v.Root(), filepath.FromSlash(rel)) - if err := os.WriteFile(abs, []byte("# Disk Title\n\n#disk\n"), 0o600); err != nil { - t.Fatal(err) - } - info, err := os.Stat(abs) - if err != nil { - t.Fatal(err) - } - cachePath := filepath.Join(v.Root(), internalVaultDir, noteMetaCacheFile) - if err := os.MkdirAll(filepath.Dir(cachePath), 0o700); err != nil { - t.Fatal(err) - } - cache := persistedNoteMetaCache{ - Version: noteMetaCacheVersion, - Entries: []persistedNoteMetaEntry{{ - Path: rel, - MtimeMs: mtimeMs(info), - Size: info.Size(), - Meta: NoteMeta{ - Path: rel, - Title: "Cached Title", - Folder: FolderInbox, - SiblingOrder: 0, - CreatedAt: info.ModTime().UnixMilli(), - UpdatedAt: info.ModTime().UnixMilli(), - Size: info.Size(), - Tags: []string{"cached"}, - Wikilinks: []string{"Cached Target"}, - HasAttachments: false, - Excerpt: "cached excerpt", - }, - }}, - } - raw, err := json.Marshal(cache) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cachePath, raw, 0o600); err != nil { - t.Fatal(err) - } - v.invalidateNoteMetaCache() - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - meta, ok := findNoteMeta(notes, rel) - if !ok { - t.Fatalf("note %s not found in %#v", rel, notes) - } - if meta.Title != "Cached Title" || len(meta.Tags) != 1 || meta.Tags[0] != "cached" || meta.Excerpt != "cached excerpt" { - t.Fatalf("did not use matching persisted metadata: %#v", meta) - } -} - -func TestListNotesIgnoresStalePersistedMetadata(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - rel := filepath.ToSlash(filepath.Join(string(FolderInbox), "stale.md")) - abs := filepath.Join(v.Root(), filepath.FromSlash(rel)) - if err := os.WriteFile(abs, []byte("# Fresh Title\n\n#fresh\n"), 0o600); err != nil { - t.Fatal(err) - } - cachePath := filepath.Join(v.Root(), internalVaultDir, noteMetaCacheFile) - if err := os.MkdirAll(filepath.Dir(cachePath), 0o700); err != nil { - t.Fatal(err) - } - cache := persistedNoteMetaCache{ - Version: noteMetaCacheVersion, - Entries: []persistedNoteMetaEntry{{ - Path: rel, - MtimeMs: 1, - Size: 1, - Meta: NoteMeta{ - Path: rel, - Title: "Stale Title", - Folder: FolderInbox, - SiblingOrder: 0, - CreatedAt: 1, - UpdatedAt: 1, - Size: 1, - Tags: []string{"stale"}, - Wikilinks: []string{}, - HasAttachments: false, - Excerpt: "stale excerpt", - }, - }}, - } - raw, err := json.Marshal(cache) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cachePath, raw, 0o600); err != nil { - t.Fatal(err) - } - v.invalidateNoteMetaCache() - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - meta, ok := findNoteMeta(notes, rel) - if !ok { - t.Fatalf("note %s not found in %#v", rel, notes) - } - if meta.Title != "stale" || len(meta.Tags) != 1 || meta.Tags[0] != "fresh" || !strings.Contains(meta.Excerpt, "Fresh Title") { - t.Fatalf("stale persisted metadata was not ignored: %#v", meta) - } -} - -func findNoteMeta(notes []NoteMeta, path string) (NoteMeta, bool) { - for _, note := range notes { - if note.Path == path { - return note, true - } - } - return NoteMeta{}, false -} - -func textSearchMatchesPath(matches []TextSearchMatch, path string) bool { - for _, match := range matches { - if match.Path == path { - return true - } - } - return false -} - -// Compile-time assertion that ImportAsset accepts an io.Reader (silences -// unused-import lints if the asset tests are stripped down later). -var _ = io.Reader(bytes.NewReader(nil)) - -func TestArchiveRoundTripPreservesSubfolder(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := v.EnsureLayout(); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "demo"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "demo", "Tables.md"), []byte("# Tables\n"), 0o600); err != nil { - t.Fatal(err) - } - - archived, err := v.ArchiveNote("inbox/demo/Tables.md") - if err != nil { - t.Fatal(err) - } - if archived.Path != "archive/demo/Tables.md" { - t.Fatalf("archived path = %q, want archive/demo/Tables.md", archived.Path) - } - - restored, err := v.UnarchiveNote(archived.Path) - if err != nil { - t.Fatal(err) - } - if restored.Path != "inbox/demo/Tables.md" { - t.Fatalf("unarchived path = %q, want inbox/demo/Tables.md", restored.Path) - } -} - -func TestTrashRoundTripPreservesSubfolder(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := v.EnsureLayout(); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "demo"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "demo", "Tables.md"), []byte("# Tables\n"), 0o600); err != nil { - t.Fatal(err) - } - - trashed, err := v.MoveToTrash("inbox/demo/Tables.md") - if err != nil { - t.Fatal(err) - } - if trashed.Path != "trash/demo/Tables.md" { - t.Fatalf("trashed path = %q, want trash/demo/Tables.md", trashed.Path) - } - - restored, err := v.RestoreFromTrash(trashed.Path) - if err != nil { - t.Fatal(err) - } - if restored.Path != "inbox/demo/Tables.md" { - t.Fatalf("restored path = %q, want inbox/demo/Tables.md", restored.Path) - } -} - -func TestVaultSettingsWeeklyNotesRoundTrip(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - - // Mirrors what the web client POSTs: weekly notes enabled with a custom - // directory and a template, plus a daily-notes template. Before the fix - // the server struct lacked WeeklyNotes (and DailyNotes.TemplateID), so - // these were silently dropped on decode/normalize and never persisted — - // the toggle always reverted after a reload. (#117) - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - DailyNotes: DailyNotesSettings{ - Enabled: true, - Directory: "Daily", - TitlePattern: "yyyy-MM-dd-EEE", - Locale: "pt-BR", - TemplateID: "daily-tmpl", - }, - WeeklyNotes: WeeklyNotesSettings{ - Enabled: true, - Directory: "My Weeks", - TitlePattern: "yyyy-'W'ww-EEE", - Locale: "en-US", - TemplateID: "weekly-tmpl", - }, - MonthlyNotes: MonthlyNotesSettings{ - Enabled: true, - Directory: "My Months", - TitlePattern: "yyyy-MM", - Locale: "en-GB", - TemplateID: "monthly-tmpl", - }, - }); err != nil { - t.Fatal(err) - } - - got, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if !got.WeeklyNotes.Enabled { - t.Error("weekly notes enabled did not persist") - } - if got.WeeklyNotes.Directory != "My Weeks" { - t.Errorf("weekly directory = %q, want %q", got.WeeklyNotes.Directory, "My Weeks") - } - if got.WeeklyNotes.TemplateID != "weekly-tmpl" { - t.Errorf("weekly templateId = %q, want %q", got.WeeklyNotes.TemplateID, "weekly-tmpl") - } - if got.WeeklyNotes.TitlePattern != "yyyy-'W'ww-EEE" { - t.Errorf("weekly titlePattern = %q, want %q", got.WeeklyNotes.TitlePattern, "yyyy-'W'ww-EEE") - } - if got.WeeklyNotes.Locale != "en-US" { - t.Errorf("weekly locale = %q, want %q", got.WeeklyNotes.Locale, "en-US") - } - if got.DailyNotes.TemplateID != "daily-tmpl" { - t.Errorf("daily templateId = %q, want %q", got.DailyNotes.TemplateID, "daily-tmpl") - } - if got.DailyNotes.TitlePattern != "yyyy-MM-dd-EEE" { - t.Errorf("daily titlePattern = %q, want %q", got.DailyNotes.TitlePattern, "yyyy-MM-dd-EEE") - } - if got.DailyNotes.Locale != "pt-BR" { - t.Errorf("daily locale = %q, want %q", got.DailyNotes.Locale, "pt-BR") - } - if !got.MonthlyNotes.Enabled { - t.Error("monthly notes enabled did not persist") - } - if got.MonthlyNotes.Directory != "My Months" { - t.Errorf("monthly directory = %q, want %q", got.MonthlyNotes.Directory, "My Months") - } - if got.MonthlyNotes.TemplateID != "monthly-tmpl" { - t.Errorf("monthly templateId = %q, want %q", got.MonthlyNotes.TemplateID, "monthly-tmpl") - } - if got.MonthlyNotes.TitlePattern != "yyyy-MM" { - t.Errorf("monthly titlePattern = %q, want %q", got.MonthlyNotes.TitlePattern, "yyyy-MM") - } - if got.MonthlyNotes.Locale != "en-GB" { - t.Errorf("monthly locale = %q, want %q", got.MonthlyNotes.Locale, "en-GB") - } - - // The key must actually reach vault.json — the original bug was that it - // never hit disk. - raw, err := os.ReadFile(v.settingsPath()) - if err != nil { - t.Fatal(err) - } - if !bytes.Contains(raw, []byte("weeklyNotes")) { - t.Errorf("vault.json missing weeklyNotes key:\n%s", raw) - } - - // An empty weekly directory normalizes to the default, mirroring daily. - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - WeeklyNotes: WeeklyNotesSettings{Enabled: true, Directory: ""}, - }); err != nil { - t.Fatal(err) - } - got, err = v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.WeeklyNotes.Directory != DefaultWeeklyNotesDirectory { - t.Errorf("empty weekly directory = %q, want default %q", got.WeeklyNotes.Directory, DefaultWeeklyNotesDirectory) - } -} - -// The web client POSTs where new drawings / databases / task files should be -// created (Settings -> New Drawings, Databases & Tasks). Before the fix the -// server struct had none of these three FileLocationSetting fields, so they -// were silently dropped on decode/normalize and the segmented controls always -// snapped back — every new task landed in the inbox regardless of the choice, -// like #117. (#446) -func TestVaultSettingsFileLocationsRoundTrip(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - DrawingsLocation: FileLocationSetting{Mode: FileLocationActiveNote}, - DatabasesLocation: FileLocationSetting{Mode: FileLocationFolder, Folder: "assets/databases"}, - TasksLocation: FileLocationSetting{Mode: FileLocationFolder, Folder: "Tasks"}, - }); err != nil { - t.Fatal(err) - } - - got, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.DrawingsLocation.Mode != FileLocationActiveNote { - t.Errorf("drawings mode = %q, want %q", got.DrawingsLocation.Mode, FileLocationActiveNote) - } - if got.DatabasesLocation.Mode != FileLocationFolder || got.DatabasesLocation.Folder != "assets/databases" { - t.Errorf("databases location = %+v, want folder mode with assets/databases", got.DatabasesLocation) - } - if got.TasksLocation.Mode != FileLocationFolder || got.TasksLocation.Folder != "Tasks" { - t.Errorf("tasks location = %+v, want folder mode with Tasks", got.TasksLocation) - } - - // An unknown/empty mode normalizes to primary, and folder-mode paths are - // trimmed of surrounding whitespace and slashes. - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - TasksLocation: FileLocationSetting{Mode: FileLocationFolder, Folder: " /Projects/ "}, - DrawingsLocation: FileLocationSetting{Mode: "bogus"}, - }); err != nil { - t.Fatal(err) - } - got, err = v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.TasksLocation.Folder != "Projects" { - t.Errorf("tasks folder = %q, want trimmed %q", got.TasksLocation.Folder, "Projects") - } - if got.DrawingsLocation.Mode != FileLocationPrimary { - t.Errorf("unknown drawings mode = %q, want normalized %q", got.DrawingsLocation.Mode, FileLocationPrimary) - } -} - -// The web client drives the implicit-due and task-rollover behavior off two -// daily-notes booleans. They are pointers so "absent" round-trips as unset -// (the TS client applies the real default); an explicit value must survive a -// SetSettings -> GetSettings round-trip and reach vault.json, or the web -// toggles would silently revert like #117. -func TestVaultSettingsDailyTaskFlagsRoundTrip(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - - yes := true - no := false - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - DailyNotes: DailyNotesSettings{ - Enabled: true, - Directory: "Daily", - TasksDueOnNoteDate: &no, // explicitly turn the default (true) OFF - RolloverUnfinishedTasks: &yes, - }, - }); err != nil { - t.Fatal(err) - } - - got, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.DailyNotes.TasksDueOnNoteDate == nil || *got.DailyNotes.TasksDueOnNoteDate != false { - t.Errorf("tasksDueOnNoteDate = %v, want explicit false", got.DailyNotes.TasksDueOnNoteDate) - } - if got.DailyNotes.RolloverUnfinishedTasks == nil || *got.DailyNotes.RolloverUnfinishedTasks != true { - t.Errorf("rolloverUnfinishedTasks = %v, want explicit true", got.DailyNotes.RolloverUnfinishedTasks) - } - - raw, err := os.ReadFile(v.settingsPath()) - if err != nil { - t.Fatal(err) - } - if !bytes.Contains(raw, []byte("tasksDueOnNoteDate")) { - t.Errorf("vault.json missing tasksDueOnNoteDate key:\n%s", raw) - } - if !bytes.Contains(raw, []byte("rolloverUnfinishedTasks")) { - t.Errorf("vault.json missing rolloverUnfinishedTasks key:\n%s", raw) - } - - // Absent pointers must stay nil (omitted) so the client default wins. - if _, err := v.SetSettings(VaultSettings{ - PrimaryNotesLocation: PrimaryNotesInbox, - DailyNotes: DailyNotesSettings{Enabled: true, Directory: "Daily"}, - }); err != nil { - t.Fatal(err) - } - got, err = v.GetSettings() - if err != nil { - t.Fatal(err) - } - if got.DailyNotes.TasksDueOnNoteDate != nil { - t.Errorf("absent tasksDueOnNoteDate = %v, want nil", *got.DailyNotes.TasksDueOnNoteDate) - } -} - -// A file or directory the server can't read must be skipped, not abort the whole -// vault scan — otherwise one root-owned entry hides the entire vault. (#159) -func TestListSkipsUnreadableEntries(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX permission bits don't apply on Windows") - } - if os.Geteuid() == 0 { - t.Skip("permission errors are bypassed when running as root") - } - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatalf("New: %v", err) - } - if _, err := v.WriteNote("inbox/Readable.md", "ok"); err != nil { - t.Fatalf("WriteNote readable: %v", err) - } - if _, err := v.WriteNote("inbox/Locked/Secret.md", "secret"); err != nil { - t.Fatalf("WriteNote locked: %v", err) - } - - // Make the subfolder unreadable, simulating a root-owned dir the non-root - // server process can't read. Locate it by name so this is mode-independent. - var locked string - _ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { - if err == nil && d.IsDir() && d.Name() == "Locked" { - locked = p - } - return nil - }) - if locked == "" { - t.Fatal("could not locate the Locked subfolder on disk") - } - if err := os.Chmod(locked, 0o000); err != nil { - t.Fatalf("chmod: %v", err) - } - t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) - - notes, err := v.ListNotes() - if err != nil { - t.Fatalf("ListNotes aborted instead of skipping the unreadable dir: %v", err) - } - var sawReadable, sawSecret bool - for _, n := range notes { - if strings.Contains(n.Path, "Readable.md") { - sawReadable = true - } - if strings.Contains(n.Path, "Secret.md") { - sawSecret = true - } - } - if !sawReadable { - t.Errorf("readable note missing from %d listed notes", len(notes)) - } - if sawSecret { - t.Error("note inside the unreadable dir should have been skipped") - } - - if _, err := v.ListFolders(); err != nil { - t.Fatalf("ListFolders aborted instead of skipping the unreadable dir: %v", err) - } -} - -func TestDatabaseBaseFolderListedButInternalsHidden(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - // A database folder with its internals, a record-page note, and a nested dir - // (its internals + nested dirs must NOT surface as folders). - baseDir := filepath.Join(root, "inbox", "Books.base") - if err := os.MkdirAll(filepath.Join(baseDir, "pages"), 0o700); err != nil { - t.Fatal(err) - } - for name, body := range map[string]string{ - "data.csv": "id,Title\nr1,Dune\n", - "schema.json": `{"version":1}`, - "Dune.md": "# Dune", - } { - if err := os.WriteFile(filepath.Join(baseDir, name), []byte(body), 0o600); err != nil { - t.Fatal(err) - } - } - // A regular note + folder that MUST still surface. - if err := os.WriteFile(filepath.Join(root, "inbox", "Regular.md"), []byte("# Hi"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "RealFolder"), 0o700); err != nil { - t.Fatal(err) - } - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - // #527: a database's record pages ARE notes. The desktop lists them, so a - // remote vault must too, or every wikilink into a database resolves to - // nothing on the server while working locally. - if !hasNotePath(notes, "inbox/Books.base/Dune.md") { - t.Error("ListNotes dropped a database record page, so wikilinks into the database cannot resolve") - } - // The database's own machinery is not a note, and never was: only `.md` - // files are collected, so data.csv and schema.json cannot surface here. - for _, n := range notes { - if strings.HasSuffix(n.Path, "data.csv") || strings.HasSuffix(n.Path, "schema.json") { - t.Errorf("ListNotes leaked database internals as a note: %s", n.Path) - } - } - if !hasNotePath(notes, "inbox/Regular.md") { - t.Error("ListNotes dropped a regular note") - } - - folders, err := v.ListFolders() - if err != nil { - t.Fatal(err) - } - sawReal := false - sawBase := false - for _, f := range folders { - // The database folder itself lists (renderer renders it as a database)... - if f.Subpath == "Books.base" { - sawBase = true - continue - } - // ...but nothing INSIDE it (e.g. Books.base/pages) is exposed as a folder. - if strings.Contains(f.Subpath, ".base/") { - t.Errorf("ListFolders leaked a database-internal folder: %s", f.Subpath) - } - if f.Subpath == "RealFolder" { - sawReal = true - } - } - if !sawBase { - t.Error("ListFolders should list the .base database folder itself") - } - if !sawReal { - t.Error("ListFolders dropped a regular folder") - } - - assets, err := v.ListAssets() - if err != nil { - t.Fatal(err) - } - for _, a := range assets { - if strings.Contains(a.Path, ".base") { - t.Errorf("ListAssets leaked a database-internal file: %s", a.Path) - } - } -} - -func hasNotePath(notes []NoteMeta, path string) bool { - for _, n := range notes { - if n.Path == path { - return true - } - } - return false -} - -func TestFavoritesRoundTripAndDedupe(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - saved, err := v.SetSettings(VaultSettings{ - // Mix of a note path and a folder key, with a duplicate and an empty entry. - Favorites: []string{"inbox/Idea.md", "inbox:Projects", "inbox/Idea.md", ""}, - }) - if err != nil { - t.Fatal(err) - } - want := []string{"inbox/Idea.md", "inbox:Projects"} - if len(saved.Favorites) != len(want) { - t.Fatalf("favorites = %v, want %v", saved.Favorites, want) - } - for i, f := range want { - if saved.Favorites[i] != f { - t.Errorf("favorites[%d] = %q, want %q", i, saved.Favorites[i], f) - } - } - // Persisted to disk and reloaded. - reloaded, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if len(reloaded.Favorites) != len(want) { - t.Errorf("reloaded favorites = %v, want %v", reloaded.Favorites, want) - } -} - -func TestFavoritesSurviveFolderRename(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox", "Projects"), 0o700); err != nil { - t.Fatal(err) - } - if _, err := v.SetSettings(VaultSettings{Favorites: []string{"inbox:Projects", "inbox/Idea.md"}}); err != nil { - t.Fatal(err) - } - if _, err := v.RenameFolder("inbox", "Projects", "Work"); err != nil { - t.Fatal(err) - } - // The server carries favorites through verbatim (the client rewrites keys). - settings, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if len(settings.Favorites) != 2 { - t.Fatalf("folder rename dropped favorites: %v", settings.Favorites) - } -} - -func TestExcalidrawListedAsNoteNotAsset(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - // A drawing whose JSON body contains a hex color (#1971c2) that must NOT - // be mistaken for a #tag, plus an image that should stay an asset. - scene := `{"type":"excalidraw","version":2,"elements":[{"strokeColor":"#1971c2"}],"appState":{},"files":{}}` - if err := os.WriteFile(filepath.Join(root, "inbox", "Sketch.excalidraw"), []byte(scene), 0o600); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "assets"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "assets", "pic.png"), []byte("PNG"), 0o600); err != nil { - t.Fatal(err) - } - - notes, err := v.ListNotes() - if err != nil { - t.Fatal(err) - } - if !hasNotePath(notes, "inbox/Sketch.excalidraw") { - t.Error("ListNotes dropped the .excalidraw drawing") - } - for _, n := range notes { - if n.Path == "inbox/Sketch.excalidraw" { - if n.Title != "Sketch" { - t.Errorf("drawing title = %q, want Sketch", n.Title) - } - if len(n.Tags) != 0 { - t.Errorf("drawing leaked tags from JSON hex colors: %v", n.Tags) - } - } - } - - assets, err := v.ListAssets() - if err != nil { - t.Fatal(err) - } - sawImage := false - for _, a := range assets { - if strings.HasSuffix(a.Path, ".excalidraw") { - t.Errorf("ListAssets leaked a drawing: %s", a.Path) - } - if a.Path == "assets/pic.png" { - sawImage = true - } - } - if !sawImage { - t.Error("ListAssets dropped a real asset") - } -} - -func TestCreateExcalidrawSeedsEmptyScene(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - meta, err := v.CreateExcalidraw(FolderInbox, "My Drawing", "") - if err != nil { - t.Fatal(err) - } - if !strings.HasSuffix(meta.Path, ".excalidraw") { - t.Errorf("created path = %q, want a .excalidraw file", meta.Path) - } - content, err := v.ReadNote(meta.Path) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(content.Body, `"type": "excalidraw"`) { - t.Errorf("seeded scene missing excalidraw type: %s", content.Body) - } -} - -func TestListAssetsIgnoresAtomicWriteScratchFiles(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - const scratch = "Daily.md.3252272.1787800172047252.tmp" - if err := os.WriteFile(filepath.Join(root, "inbox", scratch), []byte("in-flight save"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "report.2024.01.tmp"), []byte("user file"), 0o600); err != nil { - t.Fatal(err) - } - - assets, err := v.ListAssets() - if err != nil { - t.Fatal(err) - } - sawUserFile := false - for _, asset := range assets { - if asset.Name == scratch { - t.Fatalf("ListAssets leaked an atomic save scratch file: %s", asset.Path) - } - if asset.Name == "report.2024.01.tmp" { - sawUserFile = true - } - } - if !sawUserFile { - t.Fatal("ListAssets dropped a user-authored .tmp file that does not match the atomic-save pattern") - } -} - -func TestRenameAndMovePreserveExcalidrawExt(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - created, err := v.CreateExcalidraw(FolderInbox, "Diagram", "") - if err != nil { - t.Fatal(err) - } - - renamed, err := v.RenameNote(created.Path, "Flowchart") - if err != nil { - t.Fatal(err) - } - if !strings.HasSuffix(renamed.Path, ".excalidraw") { - t.Errorf("rename dropped the extension: %q", renamed.Path) - } - - moved, err := v.MoveNote(renamed.Path, FolderArchive, "") - if err != nil { - t.Fatal(err) - } - if !strings.HasSuffix(moved.Path, ".excalidraw") { - t.Errorf("move dropped the extension: %q", moved.Path) - } -} - -// CreateNote seeds the same `# Title` body the desktop app writes (main -// vault.ts and the MCP vault-ops both do). A remote vault otherwise creates -// blank notes where a local one has its title, which is most visible on daily -// notes, whose date heading is the whole point. -func TestCreateNoteSeedsTheTitleHeadingLikeTheDesktopApp(t *testing.T) { - v, err := New(t.TempDir(), Options{}) - if err != nil { - t.Fatal(err) - } - - meta, err := v.CreateNote(FolderInbox, "2026-08-04", "Daily Notes") - if err != nil { - t.Fatal(err) - } - body, err := os.ReadFile(filepath.Join(v.Root(), filepath.FromSlash(meta.Path))) - if err != nil { - t.Fatal(err) - } - if string(body) != "# 2026-08-04\n\n" { - t.Fatalf("seeded body = %q, want %q", string(body), "# 2026-08-04\n\n") - } - - // A deduped file heads itself by its final on-disk stem, like the desktop. - if _, err := v.CreateNote(FolderInbox, "Note", ""); err != nil { - t.Fatal(err) - } - second, err := v.CreateNote(FolderInbox, "Note", "") - if err != nil { - t.Fatal(err) - } - if second.Path != "inbox/Note 2.md" { - t.Fatalf("deduped path = %q, want inbox/Note 2.md", second.Path) - } - body, err = os.ReadFile(filepath.Join(v.Root(), filepath.FromSlash(second.Path))) - if err != nil { - t.Fatal(err) - } - if string(body) != "# Note 2\n\n" { - t.Fatalf("deduped body = %q, want %q", string(body), "# Note 2\n\n") - } -} - -func TestHarperSettingsRoundTripAndNormalize(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - saved, err := v.SetSettings(VaultSettings{ - Harper: &HarperSettings{ - Words: []string{" zennotes ", "zennotes", ""}, - IgnoredLints: []string{"9722060015410969502", "not-a-hash", "9722060015410969502"}, - }, - }) - if err != nil { - t.Fatal(err) - } - if saved.Harper == nil { - t.Fatal("harper settings dropped on save") - } - if len(saved.Harper.Words) != 1 || saved.Harper.Words[0] != "zennotes" { - t.Errorf("words = %v, want [zennotes]", saved.Harper.Words) - } - if len(saved.Harper.IgnoredLints) != 1 || saved.Harper.IgnoredLints[0] != "9722060015410969502" { - t.Errorf("ignoredLints = %v, want the one digit string", saved.Harper.IgnoredLints) - } - reloaded, err := v.GetSettings() - if err != nil { - t.Fatal(err) - } - if reloaded.Harper == nil || reloaded.Harper.Words[0] != "zennotes" { - t.Errorf("reloaded harper = %+v", reloaded.Harper) - } - cleared, err := v.SetSettings(VaultSettings{Harper: &HarperSettings{}}) - if err != nil { - t.Fatal(err) - } - if cleared.Harper != nil { - t.Errorf("empty harper block should be dropped, got %+v", cleared.Harper) - } -} - -func TestNoteCommentsKeepAuthorAndThreadReplies(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - meta, err := v.WriteNote("inbox/Reviewed.md", "line one\nline two\nline three") - if err != nil { - t.Fatalf("write note: %v", err) - } - written, err := v.WriteNoteComments(meta.Path, []NoteComment{ - {ID: "c1", Body: "Is this right?", CreatedAt: 1, UpdatedAt: 1}, - {ID: "c2", Body: "Yes, see line 3.", CreatedAt: 2, UpdatedAt: 2, Author: " Claude Code ", ParentID: " c1 "}, - {ID: "c3", Body: "orphan", CreatedAt: 3, UpdatedAt: 3, ParentID: "missing"}, - }) - if err != nil { - t.Fatalf("write comments: %v", err) - } - if len(written) != 3 { - t.Fatalf("expected 3 comments, got %d", len(written)) - } - read, err := v.ReadNoteComments(meta.Path) - if err != nil { - t.Fatalf("read comments: %v", err) - } - if read[1].Author != "Claude Code" || read[1].ParentID != "c1" { - t.Fatalf("reply lost its author or parent: %#v", read[1]) - } - if read[0].Author != "" || read[0].ParentID != "" { - t.Fatalf("top-level comment gained fields: %#v", read[0]) - } - if read[2].ParentID != "" { - t.Fatalf("orphan reply kept a missing parent: %#v", read[2]) - } -} diff --git a/apps/server/internal/vault/wikilink_rename.go b/apps/server/internal/vault/wikilink_rename.go deleted file mode 100644 index f3bc671e..00000000 --- a/apps/server/internal/vault/wikilink_rename.go +++ /dev/null @@ -1,235 +0,0 @@ -package vault - -import ( - "regexp" - "strings" -) - -// Rewriting inbound [[wikilinks]] when a note is renamed. The resolution here -// mirrors packages/app-core/src/lib/wikilinks.ts and the desktop main's -// wikilink-rename.ts: a target resolves by note title (case-insensitive) unless -// it looks like a path, in which case it resolves by explicit/suffix path match. - -var wikilinkRewriteRe = regexp.MustCompile(`(!?)\[\[([^\]\n]+)\]\]`) - -var wikiTopFolders = []string{"inbox", "quick", "archive", "trash"} - -func wikiNormalizeSlashes(value string) string { - value = strings.ReplaceAll(value, "\\", "/") - for strings.Contains(value, "//") { - value = strings.ReplaceAll(value, "//", "/") - } - return value -} - -func wikiStripMd(value string) string { - if strings.HasSuffix(strings.ToLower(value), ".md") { - return value[:len(value)-3] - } - return value -} - -func wikiNormCompare(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -func wikiIsPathLike(target string) bool { - t := strings.TrimSpace(target) - return strings.HasPrefix(t, "/") || strings.Contains(t, "/") || - strings.HasSuffix(strings.ToLower(t), ".md") -} - -func wikiResolveExplicitPath(notes []NoteMeta, target string) (NoteMeta, bool) { - normalized := wikiNormalizeSlashes(strings.TrimSpace(target)) - if normalized == "" { - return NoteMeta{}, false - } - trimmed := strings.Trim(wikiStripMd(normalized), "/") - if trimmed == "" { - return NoteMeta{}, false - } - relPath := "" - if strings.HasPrefix(normalized, "/") { - relPath = "inbox/" + trimmed + ".md" - } else { - lower := strings.ToLower(trimmed) - for _, f := range wikiTopFolders { - if strings.HasPrefix(lower, f+"/") { - relPath = trimmed + ".md" - break - } - } - } - if relPath == "" { - return NoteMeta{}, false - } - needle := wikiNormCompare(relPath) - for _, n := range notes { - if wikiNormCompare(n.Path) == needle { - return n, true - } - } - return NoteMeta{}, false -} - -func wikiResolvePathSuffix(notes []NoteMeta, target string) (NoteMeta, bool) { - trimmed := strings.Trim(wikiStripMd(wikiNormalizeSlashes(strings.TrimSpace(target))), "/") - if trimmed == "" { - return NoteMeta{}, false - } - suffix := wikiNormCompare("/" + trimmed + ".md") - exact := wikiNormCompare(trimmed + ".md") - var match NoteMeta - count := 0 - for _, n := range notes { - p := wikiNormCompare(n.Path) - if p == exact || strings.HasSuffix(p, suffix) { - match = n - count++ - } - } - if count == 1 { - return match, true - } - return NoteMeta{}, false -} - -func wikiResolveTarget(notes []NoteMeta, target string) (NoteMeta, bool) { - visible := make([]NoteMeta, 0, len(notes)) - for _, n := range notes { - if n.Folder != FolderTrash { - visible = append(visible, n) - } - } - if wikiIsPathLike(target) { - if n, ok := wikiResolveExplicitPath(visible, target); ok { - return n, true - } - return wikiResolvePathSuffix(visible, target) - } - needle := wikiNormCompare(wikiStripMd(target)) - for _, n := range visible { - if wikiNormCompare(n.Title) == needle { - return n, true - } - } - return NoteMeta{}, false -} - -func wikiSplitContent(content string) (target, anchor, alias string) { - rest := content - if pipe := strings.IndexByte(rest, '|'); pipe >= 0 { - alias = rest[pipe:] - rest = rest[:pipe] - } - if idx := strings.IndexAny(rest, "#^"); idx >= 0 { - anchor = rest[idx:] - rest = rest[:idx] - } - return rest, anchor, alias -} - -func wikiSwapBasename(target, newTitle string) string { - dir := "" - base := target - if slash := strings.LastIndexByte(target, '/'); slash >= 0 { - dir = target[:slash+1] - base = target[slash+1:] - } - md := "" - if strings.HasSuffix(strings.ToLower(base), ".md") { - md = base[len(base)-3:] - } - return dir + newTitle + md -} - -// wikiCodeMask marks byte positions inside fenced (line-start ``` / ~~~) or -// inline (`...`) code so links there are left untouched. RE2 has no lazy -// quantifiers, so we scan rather than match the whole block with a regex. -func wikiCodeMask(body string) []bool { - mask := make([]bool, len(body)) - lineStart := 0 - inFence := false - for i := 0; i <= len(body); i++ { - if i == len(body) || body[i] == '\n' { - line := body[lineStart:i] - trimmed := strings.TrimLeft(line, " \t") - isFence := strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") - switch { - case inFence: - for j := lineStart; j < i; j++ { - mask[j] = true - } - if isFence { - inFence = false - } - case isFence: - inFence = true - for j := lineStart; j < i; j++ { - mask[j] = true - } - default: - wikiMarkInlineCode(mask, line, lineStart) - } - lineStart = i + 1 - } - } - return mask -} - -func wikiMarkInlineCode(mask []bool, line string, offset int) { - open := -1 - for i := 0; i < len(line); i++ { - if line[i] != '`' { - continue - } - if open < 0 { - open = i - } else { - for j := open; j <= i; j++ { - mask[offset+j] = true - } - open = -1 - } - } -} - -// rewriteWikilinksForRename rewrites every [[target]] / ![[target]] in body -// whose target resolves to the note at oldPath, pointing it at newTitle. Aliases, -// #heading / ^block anchors, and embeds are preserved; code is skipped. notes -// must reflect the pre-rename vault so links resolve to what they currently target. -func rewriteWikilinksForRename(body string, notes []NoteMeta, oldPath, newTitle string) (string, int) { - matches := wikilinkRewriteRe.FindAllStringSubmatchIndex(body, -1) - if len(matches) == 0 { - return body, 0 - } - mask := wikiCodeMask(body) - var sb strings.Builder - last := 0 - changed := 0 - for _, m := range matches { - start, end := m[0], m[1] - if mask[start] { - continue - } - embed := body[m[2]:m[3]] - content := body[m[4]:m[5]] - target, anchor, alias := wikiSplitContent(content) - if n, ok := wikiResolveTarget(notes, target); !ok || n.Path != oldPath { - continue - } - newTarget := wikiSwapBasename(target, newTitle) - if newTarget == target { - continue - } - sb.WriteString(body[last:start]) - sb.WriteString(embed + "[[" + newTarget + anchor + alias + "]]") - last = end - changed++ - } - if changed == 0 { - return body, 0 - } - sb.WriteString(body[last:]) - return sb.String(), changed -} diff --git a/apps/server/internal/vault/wikilink_rename_test.go b/apps/server/internal/vault/wikilink_rename_test.go deleted file mode 100644 index 3a297655..00000000 --- a/apps/server/internal/vault/wikilink_rename_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package vault - -import "testing" - -func renameTestNotes() []NoteMeta { - return []NoteMeta{ - {Path: "inbox/demo/Old Title.md", Title: "Old Title", Folder: FolderInbox}, - {Path: "inbox/Other.md", Title: "Other", Folder: FolderInbox}, - } -} - -func TestRewriteWikilinksForRename(t *testing.T) { - notes := renameTestNotes() - rw := func(body string) (string, int) { - return rewriteWikilinksForRename(body, notes, "inbox/demo/Old Title.md", "New Title") - } - - cases := []struct { - name, in, want string - changed int - }{ - {"title", "See [[Old Title]] here.", "See [[New Title]] here.", 1}, - {"alias", "[[Old Title|the old one]]", "[[New Title|the old one]]", 1}, - {"heading", "[[Old Title#Intro]]", "[[New Title#Intro]]", 1}, - {"block", "[[Old Title^a1b2]]", "[[New Title^a1b2]]", 1}, - {"heading+alias", "[[Old Title#Intro|see]]", "[[New Title#Intro|see]]", 1}, - {"embed", "![[Old Title]]", "![[New Title]]", 1}, - {"path", "[[inbox/demo/Old Title]]", "[[inbox/demo/New Title]]", 1}, - {"path-rel", "[[demo/Old Title]]", "[[demo/New Title]]", 1}, - {"path-slash", "[[/demo/Old Title]]", "[[/demo/New Title]]", 1}, - {"path-md", "[[inbox/demo/Old Title.md]]", "[[inbox/demo/New Title.md]]", 1}, - {"multiple", "[[Old Title]] and [[Old Title|x]]", "[[New Title]] and [[New Title|x]]", 2}, - {"other", "[[Other]] stays", "[[Other]] stays", 0}, - {"none", "nothing here", "nothing here", 0}, - {"inline-code", "use `[[Old Title]]` literally", "use `[[Old Title]]` literally", 0}, - {"fenced-code", "```\n[[Old Title]]\n```", "```\n[[Old Title]]\n```", 0}, - {"code-then-link", "`[[Old Title]]` then [[Old Title]]", "`[[Old Title]]` then [[New Title]]", 1}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got, changed := rw(c.in) - if got != c.want || changed != c.changed { - t.Fatalf("rewrite(%q) = (%q, %d), want (%q, %d)", c.in, got, changed, c.want, c.changed) - } - }) - } -} - -func TestRewriteWikilinksAmbiguousTitle(t *testing.T) { - dup := []NoteMeta{ - {Path: "inbox/A.md", Title: "Dup", Folder: FolderInbox}, - {Path: "inbox/B.md", Title: "Dup", Folder: FolderInbox}, - } - // [[Dup]] resolves to the first match (inbox/A.md). Renaming B must not - // touch it; renaming A must. - if _, changed := rewriteWikilinksForRename("[[Dup]]", dup, "inbox/B.md", "New"); changed != 0 { - t.Fatalf("renaming B should not rewrite [[Dup]] (resolves to A), changed=%d", changed) - } - if got, _ := rewriteWikilinksForRename("[[Dup]]", dup, "inbox/A.md", "New"); got != "[[New]]" { - t.Fatalf("renaming A should rewrite [[Dup]] -> [[New]], got %q", got) - } -} - -// End-to-end: a real RenameNote should rewrite inbound links across the vault. -func TestRenameNoteRewritesInboundWikilinks(t *testing.T) { - root := t.TempDir() - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - if _, err := v.WriteNote("inbox/Target.md", "# Target\n"); err != nil { - t.Fatal(err) - } - src := "See [[Target]], [[Target|alias]], and ![[Target]].\n\nCode stays: `[[Target]]`\n" - if _, err := v.WriteNote("inbox/Source.md", src); err != nil { - t.Fatal(err) - } - - meta, err := v.RenameNote("inbox/Target.md", "Renamed") - if err != nil { - t.Fatal(err) - } - if meta.Title != "Renamed" { - t.Fatalf("renamed title = %q, want Renamed", meta.Title) - } - - got, err := v.ReadNote("inbox/Source.md") - if err != nil { - t.Fatal(err) - } - want := "See [[Renamed]], [[Renamed|alias]], and ![[Renamed]].\n\nCode stays: `[[Target]]`\n" - if got.Body != want { - t.Fatalf("source after rename =\n%q\nwant\n%q", got.Body, want) - } -} diff --git a/apps/server/internal/vault/workflows.go b/apps/server/internal/vault/workflows.go deleted file mode 100644 index 21b53a41..00000000 --- a/apps/server/internal/vault/workflows.go +++ /dev/null @@ -1,809 +0,0 @@ -package vault - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "runtime" - "sort" - "strings" - "time" - "unicode/utf8" -) - -const ( - workflowsRelDir = ".zennotes/workflows" - workflowRunsRelDir = ".zennotes/workflows/.runs" - workflowLedgerVersion = 1 - maxWorkflowSlugLength = 64 - maxWorkflowIDLength = 256 - maxWorkflowOps = 5000 - maxWorkflowChanges = 10000 - maxRetainedWorkflowRuns = 100 - maxRetainedWorkflowRunByte = 50 * 1024 * 1024 -) - -var ( - ErrInvalidWorkflow = errors.New("invalid workflow request") - ErrWorkflowConflict = errors.New("workflow plan is stale") - workflowRunIDPattern = regexp.MustCompile(`^[A-Za-z0-9-]{1,160}$`) -) - -type WorkflowFile struct { - ID string `json:"id"` - SourcePath string `json:"sourcePath"` - Raw string `json:"raw"` -} - -type WriteWorkflowInput struct { - Slug string `json:"slug"` - Raw string `json:"raw"` - PreviousSourcePath string `json:"previousSourcePath,omitempty"` -} - -type WorkflowRunFileChange struct { - Path string `json:"path"` - Before *string `json:"before"` - After *string `json:"after"` -} - -type PreparedWorkflowRun struct { - WorkflowID string `json:"workflowId"` - Ops []json.RawMessage `json:"ops"` - Applied int `json:"applied"` - Irreversible int `json:"irreversible"` - Changes []WorkflowRunFileChange `json:"changes"` -} - -type WorkflowRunReceipt struct { - RunID string `json:"runId"` - WorkflowID string `json:"workflowId"` - StartedAt int64 `json:"startedAt"` - Applied int `json:"applied"` - Paths []string `json:"paths"` - Irreversible int `json:"irreversible"` - RolledBack *WorkflowRollback `json:"rolledBack,omitempty"` -} - -type WorkflowRollback struct { - Reason string `json:"reason"` -} - -type WorkflowUndoResult struct { - RunID string `json:"runId"` - Restored int `json:"restored"` - DriftedPaths []string `json:"driftedPaths,omitempty"` -} - -type WorkflowRunSummary struct { - RunID string `json:"runId"` - WorkflowID string `json:"workflowId"` - StartedAt int64 `json:"startedAt"` - Applied int `json:"applied"` - Paths []string `json:"paths"` - Undoable bool `json:"undoable"` - Interrupted bool `json:"interrupted,omitempty"` -} - -type workflowJournalEntry struct { - Path string `json:"path"` - Before *string `json:"before"` -} - -type workflowRunLedger struct { - Version int `json:"version"` - RunID string `json:"runId"` - WorkflowID string `json:"workflowId"` - StartedAt int64 `json:"startedAt"` - FinishedAt int64 `json:"finishedAt"` - Applied int `json:"applied"` - Irreversible int `json:"irreversible"` - Paths []string `json:"paths"` - Ops []json.RawMessage `json:"ops"` - Journal []workflowJournalEntry `json:"journal"` - Hashes map[string]*string `json:"hashes"` - Undone bool `json:"undone"` - UndoneAt int64 `json:"undoneAt,omitempty"` - RolledBack *WorkflowRollback `json:"rolledBack,omitempty"` - Interrupted *WorkflowRollback `json:"interrupted,omitempty"` -} - -func workflowDir(root string) string { - return filepath.Join(root, ".zennotes", "workflows") -} - -func safeWorkflowSlug(value string) string { - var out strings.Builder - dash := false - for _, r := range strings.ToLower(strings.TrimSpace(value)) { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - if dash && out.Len() > 0 && out.Len() < maxWorkflowSlugLength { - out.WriteByte('-') - } - dash = false - if out.Len() < maxWorkflowSlugLength { - out.WriteRune(r) - } - continue - } - dash = true - } - result := strings.Trim(out.String(), "-") - if result == "" { - return "workflow" - } - return result -} - -func (v *Vault) resolveWorkflowFilePath(sourcePath string) (string, error) { - abs, err := SafeJoin(v.root, sourcePath) - if err != nil { - return "", err - } - dir := workflowDir(v.root) - rel, err := filepath.Rel(dir, abs) - if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(rel, string(filepath.Separator)) { - return "", fmt.Errorf("%w: refusing workflow path outside workflows dir", ErrInvalidWorkflow) - } - if !strings.EqualFold(filepath.Ext(rel), ".md") { - return "", fmt.Errorf("%w: workflow path must be a .md file", ErrInvalidWorkflow) - } - return abs, nil -} - -func workflowIDForName(name string) string { - return strings.TrimSuffix(name, filepath.Ext(name)) -} - -func (v *Vault) ListWorkflows() ([]WorkflowFile, error) { - v.mu.RLock() - defer v.mu.RUnlock() - entries, err := os.ReadDir(workflowDir(v.root)) - if errors.Is(err, os.ErrNotExist) { - return []WorkflowFile{}, nil - } - if err != nil { - return nil, err - } - out := make([]WorkflowFile, 0, len(entries)) - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".md") { - continue - } - sourcePath := workflowsRelDir + "/" + name - abs, err := v.resolveWorkflowFilePath(sourcePath) - if err != nil { - continue - } - raw, err := os.ReadFile(abs) - if err != nil { - continue - } - out = append(out, WorkflowFile{ID: workflowIDForName(name), SourcePath: sourcePath, Raw: string(raw)}) - } - sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) - return out, nil -} - -func (v *Vault) WriteWorkflow(input WriteWorkflowInput) (WorkflowFile, error) { - v.mu.Lock() - defer v.mu.Unlock() - name := safeWorkflowSlug(input.Slug) + ".md" - sourcePath := workflowsRelDir + "/" + name - abs, err := v.resolveWorkflowFilePath(sourcePath) - if err != nil { - return WorkflowFile{}, err - } - var previous string - if input.PreviousSourcePath != "" { - previous, err = v.resolveWorkflowFilePath(input.PreviousSourcePath) - if err != nil { - return WorkflowFile{}, err - } - } - if err := writeFileAtomic(abs, []byte(input.Raw), v.fileMode, v.dirMode); err != nil { - return WorkflowFile{}, err - } - if previous != "" && previous != abs { - // On a case-insensitive filesystem two differently-cased paths can name - // the SAME file, and writeFileAtomic just landed the new content on it; - // a spelling compare then let os.Remove delete the workflow that was - // just saved. Compare file identity, not path strings. - sameFile := false - if prevInfo, statErr := os.Stat(previous); statErr == nil { - if newInfo, statErr := os.Stat(abs); statErr == nil && os.SameFile(prevInfo, newInfo) { - sameFile = true - } - } - if !sameFile { - if err := os.Remove(previous); err != nil && !errors.Is(err, os.ErrNotExist) { - return WorkflowFile{}, err - } - } - } - return WorkflowFile{ID: workflowIDForName(name), SourcePath: sourcePath, Raw: input.Raw}, nil -} - -func (v *Vault) DeleteWorkflow(sourcePath string) error { - v.mu.Lock() - defer v.mu.Unlock() - abs, err := v.resolveWorkflowFilePath(sourcePath) - if err != nil { - return err - } - if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil -} - -func workflowPathSegments(path string) []string { - return strings.Split(strings.ReplaceAll(path, "\\", "/"), "/") -} - -func (v *Vault) resolveWorkflowNotePath(rel string) (string, error) { - if rel == "" || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, "\\") || filepath.IsAbs(rel) || (len(rel) >= 2 && ((rel[0] >= 'A' && rel[0] <= 'Z') || (rel[0] >= 'a' && rel[0] <= 'z')) && rel[1] == ':') { - return "", fmt.Errorf("%w: workflow note path is absolute or empty: %s", ErrInvalidWorkflow, rel) - } - segments := workflowPathSegments(rel) - for _, segment := range segments { - if segment == ".." { - return "", fmt.Errorf("%w: workflow note path escapes the vault: %s", ErrInvalidWorkflow, rel) - } - } - if len(segments) > 0 && strings.EqualFold(segments[0], internalVaultDir) { - return "", fmt.Errorf("%w: workflow note path is inside %s: %s", ErrInvalidWorkflow, internalVaultDir, rel) - } - ext := strings.ToLower(filepath.Ext(rel)) - if ext != ".md" && ext != excalidrawExt { - return "", fmt.Errorf("%w: workflow path is not a note: %s", ErrInvalidWorkflow, rel) - } - return SafeJoin(v.root, rel) -} - -func nullableString(value string) *string { - copy := value - return © -} - -func readOptionalText(abs string) (*string, error) { - body, err := os.ReadFile(abs) - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, err - } - return nullableString(string(body)), nil -} - -func optionalStringsEqual(left, right *string) bool { - if left == nil || right == nil { - return left == nil && right == nil - } - return *left == *right -} - -// coerceUTF8ForWire mirrors what encoding/json does to a string on its way to -// the client: every invalid UTF-8 byte becomes one U+FFFD replacement. The -// client can never echo back bytes JSON already destroyed, so before-bytes -// comparisons must compare against this view of the disk, byte-for-byte -// identical to what /notes/read served. -func coerceUTF8ForWire(s string) string { - if utf8.ValidString(s) { - return s - } - var b strings.Builder - b.Grow(len(s)) - for i := 0; i < len(s); { - r, size := utf8.DecodeRuneInString(s[i:]) - if r == utf8.RuneError && size == 1 { - b.WriteRune(utf8.RuneError) - i++ - continue - } - b.WriteString(s[i : i+size]) - i += size - } - return b.String() -} - -func optionalWireEqual(disk, client *string) bool { - if optionalStringsEqual(disk, client) { - return true - } - if disk == nil || client == nil { - return false - } - return coerceUTF8ForWire(*disk) == *client -} - -func workflowJournalKey(path string) string { - if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { - return strings.ToLower(path) - } - return path -} - -func workflowHash(value *string) *string { - if value == nil { - return nil - } - hash := sha256.Sum256([]byte(*value)) - encoded := hex.EncodeToString(hash[:]) - return &encoded -} - -func newWorkflowRunID(startedAt int64) string { - var suffix [6]byte - if _, err := rand.Read(suffix[:]); err != nil { - return fmt.Sprintf("%013d-%d", startedAt, time.Now().UnixNano()) - } - return fmt.Sprintf("%013d-%s", startedAt, hex.EncodeToString(suffix[:])) -} - -func (v *Vault) resolveWorkflowLedgerPath(runID string) (string, error) { - if !workflowRunIDPattern.MatchString(runID) { - return "", fmt.Errorf("%w: invalid workflow run id", ErrInvalidWorkflow) - } - return SafeJoin(v.root, workflowRunsRelDir+"/"+runID+".json") -} - -func (v *Vault) resolveWorkflowRunsDir() (string, error) { - return SafeJoin(v.root, workflowRunsRelDir) -} - -func (v *Vault) writeWorkflowLedgerLocked(ledger workflowRunLedger) error { - abs, err := v.resolveWorkflowLedgerPath(ledger.RunID) - if err != nil { - return err - } - body, err := json.MarshalIndent(ledger, "", " ") - if err != nil { - return err - } - body = append(body, '\n') - return writeFileAtomic(abs, body, v.fileMode, v.dirMode) -} - -func (v *Vault) readWorkflowLedgerLocked(runID string) (workflowRunLedger, error) { - abs, err := v.resolveWorkflowLedgerPath(runID) - if err != nil { - return workflowRunLedger{}, err - } - body, err := os.ReadFile(abs) - if err != nil { - return workflowRunLedger{}, err - } - var ledger workflowRunLedger - if err := json.Unmarshal(body, &ledger); err != nil { - return workflowRunLedger{}, err - } - if ledger.Version != workflowLedgerVersion || ledger.RunID != runID { - return workflowRunLedger{}, fmt.Errorf("%w: unsupported workflow run ledger", ErrInvalidWorkflow) - } - return ledger, nil -} - -func (v *Vault) restoreWorkflowJournalLocked(journal []workflowJournalEntry) (int, []error) { - return v.restoreWorkflowJournalSnapshotLocked(journal, nil, nil) -} - -// restoreWorkflowJournalSnapshotLocked restores the journal, consulting an -// optional pre-read snapshot (liveByPath/absByPath) so a caller that already -// read every file, like undo's drift check, does not read the whole run a -// second time while holding the exclusive vault lock. Entries missing from -// the snapshot fall back to resolving and reading here. -func (v *Vault) restoreWorkflowJournalSnapshotLocked( - journal []workflowJournalEntry, - liveByPath map[string]*string, - absByPath map[string]string, -) (int, []error) { - restored := 0 - failures := []error{} - for _, entry := range journal { - abs, haveAbs := absByPath[entry.Path] - if !haveAbs { - resolved, err := v.resolveWorkflowNotePath(entry.Path) - if err != nil { - failures = append(failures, fmt.Errorf("%s: %w", entry.Path, err)) - continue - } - abs = resolved - } - live, haveLive := liveByPath[entry.Path] - if !haveLive { - read, err := readOptionalText(abs) - if err != nil { - failures = append(failures, fmt.Errorf("%s: %w", entry.Path, err)) - continue - } - live = read - } - if optionalStringsEqual(live, entry.Before) { - continue - } - var err error - if entry.Before == nil { - err = os.Remove(abs) - if errors.Is(err, os.ErrNotExist) { - err = nil - } - } else { - err = writeFileAtomic(abs, []byte(*entry.Before), v.fileMode, v.dirMode) - } - if err != nil { - failures = append(failures, fmt.Errorf("%s: %w", entry.Path, err)) - continue - } - restored++ - } - return restored, failures -} - -func workflowFailureMessage(failures []error) string { - parts := make([]string, len(failures)) - for index, err := range failures { - parts[index] = err.Error() - } - return strings.Join(parts, "; ") -} - -// requiredWorkflowOpFields is the Go mirror of the workflow op schema. Three -// synced copies exist and MUST change together (the stripCodeContent rule): -// the op types in packages/shared-domain/src/workflows/types.ts, the -// parseWorkflowOp validator in packages/shared-domain/src/workflows/ -// prepare-run.ts (duplicated into apps/desktop/src/main/workflow-apply.ts), -// and this map. Miss this one and every web run carrying the new op kind -// 400s as "not valid" while desktop applies it fine. -var requiredWorkflowOpFields = map[string][]string{ - "set-frontmatter": {"path", "field", "value"}, - "add-tag": {"path", "tag"}, - "remove-tag": {"path", "tag"}, - "move": {"path", "to"}, - "rename": {"path", "to"}, - "append": {"path", "text"}, - "prepend": {"path", "text"}, - "write-section": {"path", "heading", "text"}, - "write-note": {"path", "text"}, - "create-note": {"path", "body"}, - "apply-template": {"path", "template"}, - "archive": {"path"}, - "trash": {"path"}, - "notify": {"message"}, - "clipboard": {"text"}, -} - -func validatePreparedWorkflowOps(ops []json.RawMessage) (int, error) { - irreversible := 0 - for index, raw := range ops { - var op map[string]json.RawMessage - if err := json.Unmarshal(raw, &op); err != nil { - return 0, fmt.Errorf("%w: workflow op %d is not valid", ErrInvalidWorkflow, index) - } - var kind string - if err := json.Unmarshal(op["kind"], &kind); err != nil { - return 0, fmt.Errorf("%w: workflow op %d is not valid", ErrInvalidWorkflow, index) - } - required, valid := requiredWorkflowOpFields[kind] - if !valid { - return 0, fmt.Errorf("%w: workflow op %d is not valid", ErrInvalidWorkflow, index) - } - for _, field := range required { - var value string - if err := json.Unmarshal(op[field], &value); err != nil { - return 0, fmt.Errorf("%w: workflow op %d is missing string field %s", ErrInvalidWorkflow, index, field) - } - } - if kind == "notify" || kind == "clipboard" { - irreversible++ - } - } - return irreversible, nil -} - -func (v *Vault) ApplyPreparedWorkflow(input PreparedWorkflowRun) (WorkflowRunReceipt, error) { - v.mu.Lock() - defer v.mu.Unlock() - startedAt := time.Now().UnixMilli() - workflowID := strings.TrimSpace(input.WorkflowID) - if workflowID == "" { - workflowID = "unknown" - } - if len(workflowID) > maxWorkflowIDLength { - return WorkflowRunReceipt{}, fmt.Errorf("%w: workflow id is too long", ErrInvalidWorkflow) - } - // Name the cap when a run is over it: the dry run just promised success, - // so a bare "invalid counts" read as a client bug instead of a server - // limit the user can see and reason about. - if len(input.Ops) > maxWorkflowOps { - return WorkflowRunReceipt{}, fmt.Errorf("%w: this run has %d operations, over the server limit of %d; split the workflow or run it from the desktop app", ErrInvalidWorkflow, len(input.Ops), maxWorkflowOps) - } - if len(input.Changes) > maxWorkflowChanges { - return WorkflowRunReceipt{}, fmt.Errorf("%w: this run touches %d files, over the server limit of %d; split the workflow or run it from the desktop app", ErrInvalidWorkflow, len(input.Changes), maxWorkflowChanges) - } - if input.Applied < 0 || input.Irreversible < 0 || input.Applied > len(input.Ops) || input.Irreversible > len(input.Ops) { - return WorkflowRunReceipt{}, fmt.Errorf("%w: invalid workflow run counts", ErrInvalidWorkflow) - } - irreversible, err := validatePreparedWorkflowOps(input.Ops) - if err != nil { - return WorkflowRunReceipt{}, err - } - if input.Irreversible != irreversible || input.Applied != len(input.Ops)-irreversible || (len(input.Changes) > 0 && input.Applied == 0) { - return WorkflowRunReceipt{}, fmt.Errorf("%w: workflow operation counts do not match the prepared changes", ErrInvalidWorkflow) - } - - paths := make([]string, 0, len(input.Changes)) - journal := make([]workflowJournalEntry, 0, len(input.Changes)) - hashes := make(map[string]*string, len(input.Changes)) - resolved := make([]string, 0, len(input.Changes)) - seen := map[string]struct{}{} - for _, change := range input.Changes { - path := filepath.ToSlash(filepath.Clean(filepath.FromSlash(change.Path))) - abs, err := v.resolveWorkflowNotePath(path) - if err != nil { - return WorkflowRunReceipt{}, err - } - key := workflowJournalKey(path) - if _, exists := seen[key]; exists { - return WorkflowRunReceipt{}, fmt.Errorf("%w: duplicate workflow path %s", ErrInvalidWorkflow, path) - } - seen[key] = struct{}{} - live, err := readOptionalText(abs) - if err != nil { - return WorkflowRunReceipt{}, err - } - // Compare against the client's WIRE view of the file: JSON coerced any - // invalid UTF-8 to U+FFFD on the way out, so a note carrying one stray - // non-UTF-8 byte would otherwise 409 on every apply, forever, and - // re-planning reads the same lossy view so the loop never resolved. - if !optionalWireEqual(live, change.Before) { - return WorkflowRunReceipt{}, fmt.Errorf("%w: %s changed after the dry run", ErrWorkflowConflict, path) - } - paths = append(paths, path) - journal = append(journal, workflowJournalEntry{Path: path, Before: change.Before}) - hashes[path] = workflowHash(change.After) - resolved = append(resolved, abs) - } - - runID := newWorkflowRunID(startedAt) - ledger := workflowRunLedger{ - Version: workflowLedgerVersion, - RunID: runID, - WorkflowID: workflowID, - StartedAt: startedAt, - FinishedAt: startedAt, - Applied: 0, - Irreversible: input.Irreversible, - Paths: paths, - Ops: input.Ops, - Journal: journal, - Hashes: map[string]*string{}, - Undone: false, - Interrupted: &WorkflowRollback{Reason: "ZenNotes stopped while this run was still applying, so part of it may have landed. Undo restores every file it had recorded."}, - } - if len(input.Ops) > 0 { - if err := v.writeWorkflowLedgerLocked(ledger); err != nil { - return WorkflowRunReceipt{}, err - } - } - if len(input.Changes) > 0 { - defer v.invalidateTextSearchCache() - } - - for index, change := range input.Changes { - var err error - if change.After == nil { - err = os.Remove(resolved[index]) - if errors.Is(err, os.ErrNotExist) { - err = nil - } - } else { - err = writeFileAtomic(resolved[index], []byte(*change.After), v.fileMode, v.dirMode) - } - if err == nil { - continue - } - _, failures := v.restoreWorkflowJournalLocked(journal) - reason := fmt.Sprintf("%v. The run was rolled back; your vault is unchanged.", err) - if len(failures) == 0 { - if abs, pathErr := v.resolveWorkflowLedgerPath(runID); pathErr == nil { - _ = os.Remove(abs) - } - return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Paths: []string{}, Irreversible: input.Irreversible, RolledBack: &WorkflowRollback{Reason: reason}}, nil - } - reason = fmt.Sprintf("%v. ROLLBACK INCOMPLETE: %s", err, workflowFailureMessage(failures)) - ledger.FinishedAt = time.Now().UnixMilli() - ledger.RolledBack = &WorkflowRollback{Reason: reason} - ledger.Interrupted = nil - _ = v.writeWorkflowLedgerLocked(ledger) - return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Paths: paths, Irreversible: input.Irreversible, RolledBack: &WorkflowRollback{Reason: reason}}, nil - } - - if len(input.Ops) > 0 { - ledger.FinishedAt = time.Now().UnixMilli() - ledger.Applied = input.Applied - ledger.Hashes = hashes - ledger.Interrupted = nil - if err := v.writeWorkflowLedgerLocked(ledger); err != nil { - _, failures := v.restoreWorkflowJournalLocked(journal) - if len(failures) == 0 { - if abs, pathErr := v.resolveWorkflowLedgerPath(runID); pathErr == nil { - _ = os.Remove(abs) - } - return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Paths: []string{}, Irreversible: input.Irreversible, RolledBack: &WorkflowRollback{Reason: fmt.Sprintf("The run could not be recorded, so it was rolled back (%v).", err)}}, nil - } - return WorkflowRunReceipt{}, fmt.Errorf("record workflow run: %w; rollback: %s", err, workflowFailureMessage(failures)) - } - v.pruneWorkflowRunsLocked() - } - return WorkflowRunReceipt{RunID: runID, WorkflowID: workflowID, StartedAt: startedAt, Applied: input.Applied, Paths: paths, Irreversible: input.Irreversible}, nil -} - -func (v *Vault) pruneWorkflowRunsLocked() { - runsDir, err := v.resolveWorkflowRunsDir() - if err != nil { - return - } - entries, err := os.ReadDir(runsDir) - if err != nil { - return - } - type retainedFile struct { - name string - size int64 - } - files := []retainedFile{} - for _, entry := range entries { - if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") { - continue - } - info, err := entry.Info() - if err == nil { - files = append(files, retainedFile{name: entry.Name(), size: info.Size()}) - } - } - sort.Slice(files, func(i, j int) bool { return files[i].name > files[j].name }) - var total int64 - for index, file := range files { - total += file.size - // The newest ledger is the run the user is being shown right now. Keep - // it even when one whole-vault run exceeds the history byte budget, or - // pruning would remove Undo from the run that just completed. - if index == 0 || (index < maxRetainedWorkflowRuns && total <= maxRetainedWorkflowRunByte) { - continue - } - _ = os.Remove(filepath.Join(runsDir, file.name)) - } -} - -func (v *Vault) UndoWorkflowRun(runID string) (WorkflowUndoResult, error) { - v.mu.Lock() - defer v.mu.Unlock() - ledger, err := v.readWorkflowLedgerLocked(runID) - if errors.Is(err, os.ErrNotExist) { - return WorkflowUndoResult{}, fmt.Errorf("%w: unknown workflow run %s", ErrInvalidWorkflow, runID) - } - if err != nil { - return WorkflowUndoResult{}, err - } - if ledger.Undone { - return WorkflowUndoResult{}, fmt.Errorf("%w: workflow run was already undone", ErrInvalidWorkflow) - } - // One read per journaled file: the drift check and the restore both need - // the live bytes, and reading a whole-vault run twice under the exclusive - // lock doubled how long every other request stayed blocked. The lock - // guarantees nothing changes between this pass and the restore. - liveByPath := make(map[string]*string, len(ledger.Journal)) - absByPath := make(map[string]string, len(ledger.Journal)) - drifted := []string{} - for _, entry := range ledger.Journal { - abs, err := v.resolveWorkflowNotePath(entry.Path) - if err != nil { - continue - } - absByPath[entry.Path] = abs - live, err := readOptionalText(abs) - if err != nil { - continue - } - liveByPath[entry.Path] = live - if expected, tracked := ledger.Hashes[entry.Path]; tracked { - if !optionalStringsEqual(workflowHash(live), expected) { - drifted = append(drifted, entry.Path) - } - } - } - restored, failures := v.restoreWorkflowJournalSnapshotLocked(ledger.Journal, liveByPath, absByPath) - if len(failures) > 0 { - return WorkflowUndoResult{}, fmt.Errorf("undo of run %s is incomplete: %s", runID, workflowFailureMessage(failures)) - } - ledger.Undone = true - ledger.UndoneAt = time.Now().UnixMilli() - if err := v.writeWorkflowLedgerLocked(ledger); err != nil { - return WorkflowUndoResult{}, err - } - v.invalidateTextSearchCache() - return WorkflowUndoResult{RunID: runID, Restored: restored, DriftedPaths: drifted}, nil -} - -func (v *Vault) ListWorkflowRuns() ([]WorkflowRunSummary, error) { - v.mu.RLock() - defer v.mu.RUnlock() - runsDir, err := v.resolveWorkflowRunsDir() - if err != nil { - return nil, err - } - entries, err := os.ReadDir(runsDir) - if errors.Is(err, os.ErrNotExist) { - return []WorkflowRunSummary{}, nil - } - if err != nil { - return nil, err - } - runs := []WorkflowRunSummary{} - for _, entry := range entries { - if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") { - continue - } - runID := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) - ledger, err := v.readWorkflowLedgerLocked(runID) - if err != nil { - continue - } - runs = append(runs, WorkflowRunSummary{ - RunID: ledger.RunID, - WorkflowID: ledger.WorkflowID, - StartedAt: ledger.StartedAt, - Applied: ledger.Applied, - Paths: ledger.Paths, - Undoable: !ledger.Undone && len(ledger.Journal) > 0, - Interrupted: ledger.Interrupted != nil, - }) - } - sort.Slice(runs, func(i, j int) bool { - if runs[i].StartedAt != runs[j].StartedAt { - return runs[i].StartedAt > runs[j].StartedAt - } - return runs[i].RunID > runs[j].RunID - }) - return runs, nil -} - -func (v *Vault) DeleteWorkflowRuns(workflowID string) (int, error) { - v.mu.Lock() - defer v.mu.Unlock() - runsDir, err := v.resolveWorkflowRunsDir() - if err != nil { - return 0, err - } - entries, err := os.ReadDir(runsDir) - if errors.Is(err, os.ErrNotExist) { - return 0, nil - } - if err != nil { - return 0, err - } - removed := 0 - for _, entry := range entries { - if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") { - continue - } - runID := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) - ledger, err := v.readWorkflowLedgerLocked(runID) - if err != nil || ledger.WorkflowID != workflowID { - continue - } - if err := os.Remove(filepath.Join(runsDir, entry.Name())); err == nil || errors.Is(err, os.ErrNotExist) { - removed++ - } - } - return removed, nil -} diff --git a/apps/server/internal/vault/workflows_hardening_test.go b/apps/server/internal/vault/workflows_hardening_test.go deleted file mode 100644 index 23a19eba..00000000 --- a/apps/server/internal/vault/workflows_hardening_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package vault - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -// A save whose slug differs from the previous filename only by case used to -// delete the workflow that was just written: on a case-insensitive filesystem -// both spellings name one physical file, and the string-compare guard let the -// cleanup remove it. Either filesystem must end with exactly one surviving -// workflow carrying the new content. -func TestWriteWorkflowCaseOnlyRenameKeepsTheFile(t *testing.T) { - v, root := workflowTestVault(t) - dir := filepath.Join(root, ".zennotes", "workflows") - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "My-Flow.md"), []byte("old\n"), 0o600); err != nil { - t.Fatal(err) - } - - if _, err := v.WriteWorkflow(WriteWorkflowInput{ - Slug: "my-flow", - Raw: "new\n", - PreviousSourcePath: ".zennotes/workflows/My-Flow.md", - }); err != nil { - t.Fatal(err) - } - - body, err := os.ReadFile(filepath.Join(dir, "my-flow.md")) - if err != nil { - t.Fatalf("saved workflow unreadable after case-only rename: %v", err) - } - if string(body) != "new\n" { - t.Fatalf("saved workflow = %q, want the new content", body) - } -} - -// A note carrying invalid UTF-8 reaches the browser through JSON, which -// coerces the bad bytes to U+FFFD; the client can only echo that view back. -// Comparing it against raw disk bytes made every apply 409 forever. -func TestApplyPreparedWorkflowAcceptsWireCoercedBeforeBytes(t *testing.T) { - v, root := workflowTestVault(t) - raw := []byte("head \xff\xfe tail\n") - if err := os.WriteFile(filepath.Join(root, "inbox", "B.md"), raw, 0o600); err != nil { - t.Fatal(err) - } - - // What the client saw: each invalid byte as one replacement char. - before := coerceUTF8ForWire(string(raw)) - if !strings.Contains(before, "��") { - t.Fatalf("test fixture did not coerce: %q", before) - } - after := "rewritten\n" - - receipt, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "utf8", - Ops: []json.RawMessage{rawWorkflowOp(t, map[string]string{ - "kind": "write-note", "path": "inbox/B.md", "text": after, - })}, - Applied: 1, - Changes: []WorkflowRunFileChange{{Path: "inbox/B.md", Before: &before, After: &after}}, - }) - if err != nil { - t.Fatalf("apply over wire-coerced before bytes = %v, want success", err) - } - if receipt.RolledBack != nil { - t.Fatalf("run rolled back: %v", receipt.RolledBack.Reason) - } - body, err := os.ReadFile(filepath.Join(root, "inbox", "B.md")) - if err != nil || string(body) != after { - t.Fatalf("note after run = %q (%v), want %q", body, err, after) - } -} - -// An over-cap run must say WHICH limit it crossed: the dry run just promised -// success, so a bare "invalid counts" reads as a client bug. -func TestApplyPreparedWorkflowNamesTheScaleCap(t *testing.T) { - v, _ := workflowTestVault(t) - ops := make([]json.RawMessage, maxWorkflowOps+1) - for i := range ops { - ops[i] = rawWorkflowOp(t, map[string]string{"kind": "notify", "message": "x"}) - } - _, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{WorkflowID: "big", Ops: ops}) - if err == nil || !strings.Contains(err.Error(), "server limit") { - t.Fatalf("over-cap error = %v, want the limit named", err) - } -} diff --git a/apps/server/internal/vault/workflows_security_test.go b/apps/server/internal/vault/workflows_security_test.go deleted file mode 100644 index 9e58d1fd..00000000 --- a/apps/server/internal/vault/workflows_security_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package vault - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "testing" -) - -func workflowTestVault(t *testing.T) (*Vault, string) { - t.Helper() - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "inbox", "A.md"), []byte("# A\n"), 0o600); err != nil { - t.Fatal(err) - } - v, err := New(root, Options{}) - if err != nil { - t.Fatal(err) - } - return v, root -} - -func rawWorkflowOp(t *testing.T, value any) json.RawMessage { - t.Helper() - body, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - return body -} - -func TestPreparedWorkflowRequiresValidMatchingOps(t *testing.T) { - v, root := workflowTestVault(t) - before := "# A\n" - after := "# Changed\n" - - _, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "missing-op", - Changes: []WorkflowRunFileChange{{ - Path: "inbox/A.md", Before: &before, After: &after, - }}, - }) - if !errors.Is(err, ErrInvalidWorkflow) { - t.Fatalf("missing op error = %v, want ErrInvalidWorkflow", err) - } - if body, err := os.ReadFile(filepath.Join(root, "inbox", "A.md")); err != nil || string(body) != before { - t.Fatalf("missing-op request changed note to %q (%v)", body, err) - } - - _, err = v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "unknown-op", - Ops: []json.RawMessage{rawWorkflowOp(t, map[string]string{"kind": "shell"})}, - Applied: 1, - }) - if !errors.Is(err, ErrInvalidWorkflow) { - t.Fatalf("unknown op error = %v, want ErrInvalidWorkflow", err) - } - - _, err = v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "malformed-op", - Ops: []json.RawMessage{rawWorkflowOp(t, map[string]string{"kind": "write-note"})}, - Applied: 1, - }) - if !errors.Is(err, ErrInvalidWorkflow) { - t.Fatalf("malformed op error = %v, want ErrInvalidWorkflow", err) - } -} - -func TestPreparedWorkflowRejectsStaleAndInternalPaths(t *testing.T) { - v, root := workflowTestVault(t) - stale := "# Stale\n" - after := "# Changed\n" - op := rawWorkflowOp(t, map[string]string{"kind": "write-note", "path": "inbox/A.md", "text": after}) - - _, err := v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "stale", - Ops: []json.RawMessage{op}, - Applied: 1, - Changes: []WorkflowRunFileChange{{ - Path: "inbox/A.md", Before: &stale, After: &after, - }}, - }) - if !errors.Is(err, ErrWorkflowConflict) { - t.Fatalf("stale error = %v, want ErrWorkflowConflict", err) - } - - missing := (*string)(nil) - _, err = v.ApplyPreparedWorkflow(PreparedWorkflowRun{ - WorkflowID: "internal", - Ops: []json.RawMessage{op}, - Applied: 1, - Changes: []WorkflowRunFileChange{{ - Path: ".zennotes/workflows/owned.md", Before: missing, After: &after, - }}, - }) - if !errors.Is(err, ErrInvalidWorkflow) { - t.Fatalf("internal path error = %v, want ErrInvalidWorkflow", err) - } - if _, err := os.Stat(filepath.Join(root, ".zennotes", "workflows", "owned.md")); !os.IsNotExist(err) { - t.Fatalf("internal path was written: %v", err) - } -} - -func TestWorkflowRunsReadDesktopInterruptedLedger(t *testing.T) { - v, root := workflowTestVault(t) - runsDir := filepath.Join(root, ".zennotes", "workflows", ".runs") - if err := os.MkdirAll(runsDir, 0o700); err != nil { - t.Fatal(err) - } - ledger := map[string]any{ - "version": 1, - "runId": "desktop-run", - "workflowId": "desktop-workflow", - "startedAt": 1, - "finishedAt": 2, - "applied": 0, - "irreversible": 0, - "paths": []string{"inbox/A.md"}, - "ops": []any{}, - "journal": []any{map[string]any{"path": "inbox/A.md", "before": "# A\n"}}, - "hashes": map[string]any{}, - "undone": false, - "interrupted": map[string]string{"reason": "desktop stopped while applying"}, - } - body, err := json.Marshal(ledger) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(runsDir, "desktop-run.json"), body, 0o600); err != nil { - t.Fatal(err) - } - - runs, err := v.ListWorkflowRuns() - if err != nil { - t.Fatal(err) - } - if len(runs) != 1 || !runs[0].Interrupted || !runs[0].Undoable { - t.Fatalf("desktop interrupted runs = %+v", runs) - } -} - -func TestWorkflowRunsRejectSymlinkedHistoryDirectory(t *testing.T) { - v, root := workflowTestVault(t) - external := t.TempDir() - workflowDir := filepath.Join(root, ".zennotes", "workflows") - if err := os.MkdirAll(workflowDir, 0o700); err != nil { - t.Fatal(err) - } - if err := os.Symlink(external, filepath.Join(workflowDir, ".runs")); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - - if _, err := v.ListWorkflowRuns(); !errors.Is(err, ErrPathEscape) { - t.Fatalf("symlinked history error = %v, want ErrPathEscape", err) - } -} diff --git a/apps/server/internal/watcher/watcher.go b/apps/server/internal/watcher/watcher.go deleted file mode 100644 index 64c56371..00000000 --- a/apps/server/internal/watcher/watcher.go +++ /dev/null @@ -1,451 +0,0 @@ -package watcher - -import ( - "encoding/json" - "log" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/ZenNotes/zennotes/apps/server/internal/vault" - "github.com/fsnotify/fsnotify" -) - -const ( - internalVaultDir = ".zennotes" - vaultSettingsFilePath = ".zennotes/vault.json" - noteCommentsPrefix = ".zennotes/comments/" - noteCommentsSuffix = ".comments.json" - templatesPrefix = ".zennotes/templates/" -) - -// Watcher recursively watches the vault root and fans out change -// events to any subscribed channels. Mirrors the chokidar-based -// watcher in src/main/watcher.ts. -type Watcher struct { - root string - fs *fsnotify.Watcher - mu sync.Mutex - subs map[chan vault.ChangeEvent]struct{} - closed bool - stopCh chan struct{} - // dirs tracks the absolute paths we believe are directories, so a - // remove/rename event (which can't be os.Stat'd) can still be recognized - // as a folder change. Only touched from the single loop goroutine (and - // Start, before the loop begins), so it needs no separate lock. - dirs map[string]struct{} - // folderPaths holds the systemFolderPaths from vault settings for - // classifying note paths to folder IDs. - folderPaths map[string]string -} - -func (w *Watcher) SetFolderPaths(paths map[string]string) { - w.mu.Lock() - defer w.mu.Unlock() - w.folderPaths = paths -} - -func (w *Watcher) getFolderPaths() map[string]string { - w.mu.Lock() - defer w.mu.Unlock() - return w.folderPaths -} - -// reloadFolderPaths re-reads the folder overrides after vault.json changes. -// The raw map goes through the vault's normalizer, exactly as the paths seeded -// at startup did (main.go reads them from vault.GetSettings). A value the -// normalizer rejects, `trash: "assets"` say, would otherwise make the watcher -// route assets/ events to Trash while the vault, which classifies against the -// normalized settings, disagrees. -func (w *Watcher) reloadFolderPaths() { - settingsPath := filepath.Join(w.root, vaultSettingsFilePath) - raw, err := os.ReadFile(settingsPath) - if err != nil { - // A deleted vault.json means no overrides, which is what the vault - // reports too. Any other read error leaves the last known paths in place. - if os.IsNotExist(err) { - w.SetFolderPaths(nil) - } - return - } - var settings struct { - SystemFolderPaths map[string]string `json:"systemFolderPaths"` - } - if err := json.Unmarshal(raw, &settings); err != nil { - return - } - w.SetFolderPaths(vault.NormalizeSystemFolderPaths(settings.SystemFolderPaths)) -} - -func Start(root string) (*Watcher, error) { - fsw, err := fsnotify.NewWatcher() - if err != nil { - return nil, err - } - w := &Watcher{ - root: root, - fs: fsw, - subs: map[chan vault.ChangeEvent]struct{}{}, - stopCh: make(chan struct{}), - dirs: map[string]struct{}{}, - folderPaths: nil, - } - // Recursively add all existing directories under the vault. - var addErrs int - var firstAddErr error - _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - name := d.Name() - if path != root && strings.HasPrefix(name, ".") && name != internalVaultDir { - return filepath.SkipDir - } - // Don't discard the error: inotify can be exhausted or restricted - // (notably in unprivileged LXC containers), and a silent failure - // leaves clients without live updates for no apparent reason. (#179) - if addErr := fsw.Add(path); addErr != nil { - addErrs++ - if firstAddErr == nil { - firstAddErr = addErr - } - } - w.dirs[path] = struct{}{} - } - return nil - }) - if addErrs > 0 { - log.Printf("watcher: could not watch %d director(ies) (first error: %v); live updates may be incomplete — set ZENNOTES_DISABLE_WATCHER=1 if this environment restricts inotify (e.g. unprivileged LXC)", addErrs, firstAddErr) - } - go w.loop() - return w, nil -} - -// Disabled returns a watcher that does no filesystem watching. It still -// supports Subscribe/Close so the rest of the server can treat it like a real -// watcher; it simply never emits change events. Used where inotify is -// unavailable or explicitly turned off — notably unprivileged LXC containers, -// where inotify operations can wedge the process (unkillable, bind-mount -// locked) instead of returning an error. (#179) -func Disabled(root string) *Watcher { - w := &Watcher{ - root: root, - fs: nil, - subs: map[chan vault.ChangeEvent]struct{}{}, - stopCh: make(chan struct{}), - dirs: map[string]struct{}{}, - } - go w.loop() - return w -} - -// StartOrDisabled starts a real watcher, or falls back to a no-op watcher when -// watching is turned off (disable) or unavailable. It never returns an error, -// so the server can always serve the vault even where inotify is restricted. (#179) -func StartOrDisabled(root string, disable bool) *Watcher { - if disable { - log.Printf("watcher: disabled via ZENNOTES_DISABLE_WATCHER; live updates are off") - return Disabled(root) - } - w, err := Start(root) - if err != nil { - log.Printf("watcher: unavailable (%v); continuing without live updates — set ZENNOTES_DISABLE_WATCHER=1 to disable watching explicitly", err) - return Disabled(root) - } - return w -} - -// Active reports whether this watcher really watches the filesystem. The -// Disabled fallback (inotify unavailable or explicitly off, #179) has no -// fsnotify handle and never emits an event; capabilities must not promise -// live updates it cannot deliver, or clients skip polling AND never hear -// about changes. -func (w *Watcher) Active() bool { - return w != nil && w.fs != nil -} - -func (w *Watcher) Subscribe() (<-chan vault.ChangeEvent, func()) { - ch := make(chan vault.ChangeEvent, 64) - w.mu.Lock() - w.subs[ch] = struct{}{} - w.mu.Unlock() - return ch, func() { - w.mu.Lock() - if _, ok := w.subs[ch]; ok { - delete(w.subs, ch) - close(ch) - } - w.mu.Unlock() - } -} - -func (w *Watcher) Close() { - w.mu.Lock() - if w.closed { - w.mu.Unlock() - return - } - w.closed = true - close(w.stopCh) - for ch := range w.subs { - delete(w.subs, ch) - close(ch) - } - w.mu.Unlock() - if w.fs != nil { - _ = w.fs.Close() - } -} - -func (w *Watcher) loop() { - // A disabled (no-op) watcher has no fsnotify handle — just block until close. - if w.fs == nil { - <-w.stopCh - return - } - for { - select { - case <-w.stopCh: - return - case err, ok := <-w.fs.Errors: - if !ok { - return - } - log.Printf("watcher error: %v", err) - case ev, ok := <-w.fs.Events: - if !ok { - return - } - w.handle(ev) - } - } -} - -func (w *Watcher) relativePath(absPath string) string { - rel, err := filepath.Rel(w.root, absPath) - if err != nil { - return "" - } - return filepath.ToSlash(rel) -} - -func (w *Watcher) isVaultSettingsPath(absPath string) bool { - return w.relativePath(absPath) == vaultSettingsFilePath -} - -func (w *Watcher) commentsNotePath(absPath string) (string, bool) { - rel := w.relativePath(absPath) - if !strings.HasPrefix(rel, noteCommentsPrefix) || !strings.HasSuffix(rel, noteCommentsSuffix) { - return "", false - } - return strings.TrimSuffix(strings.TrimPrefix(rel, noteCommentsPrefix), noteCommentsSuffix), true -} - -// templatePath reports whether the path is a custom template: a `.md` file -// directly inside .zennotes/templates/, the flat directory the template -// routes serve. Dotfiles and nested paths are not templates there either. -func (w *Watcher) templatePath(absPath string) (string, bool) { - rel := w.relativePath(absPath) - if !strings.HasPrefix(rel, templatesPrefix) { - return "", false - } - name := strings.TrimPrefix(rel, templatesPrefix) - if name == "" || strings.Contains(name, "/") || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".md") { - return "", false - } - return rel, true -} - -// watchSubdirs adds the directories already inside a directory that just -// appeared. A tree that arrives in one go (mkdir -p, or a template write -// creating .zennotes/templates/ in a vault that had no .zennotes/ yet) raises -// one Create for the top; its children were created before that watch -// existed, so without this walk they would stay unwatched until a restart. -func (w *Watcher) watchSubdirs(dir string) { - _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil || path == dir || !d.IsDir() { - return nil - } - name := d.Name() - if strings.HasPrefix(name, ".") && name != internalVaultDir { - return filepath.SkipDir - } - if _, ok := w.dirs[path]; ok { - return nil - } - if addErr := w.fs.Add(path); addErr != nil { - log.Printf("watcher: cannot watch new directory %s: %v", path, addErr) - } - w.dirs[path] = struct{}{} - w.broadcastFolder(path, "add") - return nil - }) -} - -func (w *Watcher) handle(ev fsnotify.Event) { - base := filepath.Base(ev.Name) - // The scratch file every atomic write renames from. Its create/write/rename - // burst is not a vault change, and since the name does not end in .md a - // client would answer each one by re-listing the whole asset tree. - if vault.IsAtomicWriteTempPath(ev.Name) { - return - } - if strings.HasPrefix(base, ".") && !w.isVaultSettingsPath(ev.Name) && base != internalVaultDir { - return - } - info, statErr := os.Stat(ev.Name) - if statErr == nil && info.IsDir() { - if ev.Op&fsnotify.Create != 0 { - if err := w.fs.Add(ev.Name); err != nil { - log.Printf("watcher: cannot watch new directory %s: %v", ev.Name, err) - } - w.dirs[ev.Name] = struct{}{} - // An empty folder produces no note event, so clients would never - // learn about it until a manual refresh. Surface it explicitly. - w.broadcastFolder(ev.Name, "add") - w.watchSubdirs(ev.Name) - } - return - } - // A removed/renamed path we had tracked as a directory. We can't os.Stat - // it anymore, so the tracking set is what tells us it was a folder. - if statErr != nil { - if _, ok := w.dirs[ev.Name]; ok { - delete(w.dirs, ev.Name) - w.broadcastFolder(ev.Name, "unlink") - return - } - } - relPosix := w.relativePath(ev.Name) - if relPosix == "" { - return - } - if relPosix == vaultSettingsFilePath { - w.reloadFolderPaths() - kind := eventKind(ev, statErr == nil) - if kind == "" { - return - } - w.broadcast(vault.ChangeEvent{ - Kind: kind, - Path: relPosix, - Folder: vault.FolderInbox, - Scope: "vault-settings", - }) - return - } - if notePath, ok := w.commentsNotePath(ev.Name); ok { - kind := eventKind(ev, statErr == nil) - if kind == "" { - return - } - folder, ok := vault.FolderForRelativePathWithSettings(notePath, w.getFolderPaths()) - if !ok { - folder = vault.FolderInbox - } - w.broadcast(vault.ChangeEvent{ - Kind: kind, - Path: notePath, - Folder: folder, - Scope: "comments", - }) - return - } - if templatePath, ok := w.templatePath(ev.Name); ok { - kind := eventKind(ev, statErr == nil) - if kind == "" { - return - } - // A template is not a note: its own scope keeps clients from - // re-listing the note tree and rescanning tasks for every save. - w.broadcast(vault.ChangeEvent{ - Kind: kind, - Path: templatePath, - Folder: vault.FolderInbox, - Scope: "templates", - }) - return - } - if strings.HasPrefix(relPosix, ".") || strings.Contains(relPosix, "/.") { - return - } - folder, ok := vault.FolderForRelativePathWithSettings(relPosix, w.getFolderPaths()) - if !ok { - if relPosix == vault.AssetsDir || - strings.HasPrefix(relPosix, vault.AssetsDir+"/") || - relPosix == vault.PrimaryAttachmentsDir || - strings.HasPrefix(relPosix, vault.PrimaryAttachmentsDir+"/") || - relPosix == "_assets" || - strings.HasPrefix(relPosix, "_assets/") { - folder = vault.FolderInbox - } else { - return - } - } - - kind := eventKind(ev, statErr == nil) - if kind == "" { - return - } - - change := vault.ChangeEvent{ - Kind: kind, - Path: relPosix, - Folder: folder, - } - - w.broadcast(change) -} - -// exists says whether the path was still on disk when the event was handled, -// which is what separates a deleted note from a replaced one. -func eventKind(ev fsnotify.Event, exists bool) string { - switch { - case ev.Op&fsnotify.Create != 0: - return "add" - case ev.Op&fsnotify.Write != 0: - return "change" - case ev.Op&fsnotify.Remove != 0, ev.Op&fsnotify.Rename != 0: - // A rename into place, which is what every atomic save is, drops the - // old directory entry while the replacement is already sitting there. - // The kqueue backend (a server hosted on macOS) reports that as a - // delete of the note itself, and a client told its open note was - // deleted closes the tab. A path that still exists was replaced. - if exists { - return "add" - } - return "unlink" - default: - return "" - } -} - -func (w *Watcher) broadcastFolder(absPath, kind string) { - rel := w.relativePath(absPath) - if rel == "" { - return - } - folder, ok := vault.FolderForRelativePathWithSettings(rel, w.getFolderPaths()) - if !ok { - return - } - w.broadcast(vault.ChangeEvent{ - Kind: kind, - Path: rel, - Folder: folder, - Scope: "folder", - }) -} - -func (w *Watcher) broadcast(change vault.ChangeEvent) { - w.mu.Lock() - for ch := range w.subs { - select { - case ch <- change: - default: - } - } - w.mu.Unlock() -} diff --git a/apps/server/internal/watcher/watcher_test.go b/apps/server/internal/watcher/watcher_test.go deleted file mode 100644 index 03ecd1ef..00000000 --- a/apps/server/internal/watcher/watcher_test.go +++ /dev/null @@ -1,394 +0,0 @@ -package watcher - -import ( - "os" - "path/filepath" - "testing" - "time" - - "github.com/ZenNotes/zennotes/apps/server/internal/vault" - "github.com/fsnotify/fsnotify" -) - -// newTestWatcher builds a Watcher with a real fsnotify handle but without -// starting the event loop, so handle() can be driven deterministically -// (no dependence on real filesystem-event timing). -func newTestWatcher(t *testing.T, root string) *Watcher { - t.Helper() - fsw, err := fsnotify.NewWatcher() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = fsw.Close() }) - return &Watcher{ - root: root, - fs: fsw, - subs: map[chan vault.ChangeEvent]struct{}{}, - dirs: map[string]struct{}{}, - stopCh: make(chan struct{}), - folderPaths: nil, - } -} - -func recvChange(t *testing.T, ch <-chan vault.ChangeEvent) vault.ChangeEvent { - t.Helper() - select { - case ev := <-ch: - return ev - case <-time.After(time.Second): - t.Fatal("timed out waiting for a change event") - return vault.ChangeEvent{} - } -} - -func TestWatcherBroadcastsFolderCreateAndRemove(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - dir := filepath.Join(root, "inbox", "Projects") - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - - // Folder create — previously swallowed, so a client sharing this vault - // never learned of an empty folder until a manual refresh. - w.handle(fsnotify.Event{Name: dir, Op: fsnotify.Create}) - ev := recvChange(t, ch) - if ev.Scope != "folder" || ev.Kind != "add" || ev.Path != "inbox/Projects" { - t.Fatalf("folder create event = %+v, want {add inbox/Projects folder}", ev) - } - if _, ok := w.dirs[dir]; !ok { - t.Error("created dir was not tracked") - } - - // Folder remove — can't be stat'd once gone, so the tracking set is what - // identifies it as a directory rather than a file. - if err := os.RemoveAll(dir); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: dir, Op: fsnotify.Remove}) - ev = recvChange(t, ch) - if ev.Scope != "folder" || ev.Kind != "unlink" || ev.Path != "inbox/Projects" { - t.Fatalf("folder remove event = %+v, want {unlink inbox/Projects folder}", ev) - } - if _, ok := w.dirs[dir]; ok { - t.Error("removed dir is still tracked") - } -} - -func TestDisabledWatcherIsNoop(t *testing.T) { - root := t.TempDir() - w := Disabled(root) - if w.fs != nil { - t.Fatal("disabled watcher should have no fsnotify handle") - } - ch, unsub := w.Subscribe() - defer unsub() - - // Creating a directory must NOT produce an event — nothing is watched. - if err := os.MkdirAll(filepath.Join(root, "inbox", "Projects"), 0o700); err != nil { - t.Fatal(err) - } - select { - case ev := <-ch: - t.Fatalf("disabled watcher emitted an event: %+v", ev) - case <-time.After(100 * time.Millisecond): - // Expected: a no-op watcher never emits. - } - - // Close must be safe even though there is no fsnotify handle to close. - w.Close() -} - -func TestStartOrDisabledFallsBackWhenDisabled(t *testing.T) { - root := t.TempDir() - - disabled := StartOrDisabled(root, true) - if disabled.fs != nil { - t.Error("StartOrDisabled(_, true) should return a no-op watcher") - } - disabled.Close() - - enabled := StartOrDisabled(root, false) - if enabled.fs == nil { - t.Error("StartOrDisabled(_, false) should start a real watcher") - } - enabled.Close() -} - -func writeVaultSettings(t *testing.T, root, body string) { - t.Helper() - dir := filepath.Join(root, internalVaultDir) - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "vault.json"), []byte(body), 0o600); err != nil { - t.Fatal(err) - } -} - -func TestReloadFolderPathsNormalizesLikeTheVault(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - - // `assets` is a reserved directory name, so the vault's normalizer drops the - // override. Taking it at face value routed every assets/ event to Trash - // while the vault kept classifying those paths as assets. - writeVaultSettings(t, root, `{"systemFolderPaths":{"trash":"assets","quick":"scratch"}}`) - w.reloadFolderPaths() - - paths := w.getFolderPaths() - if _, rejected := paths["trash"]; rejected { - t.Fatalf("reserved override survived normalization: %v", paths) - } - if paths["quick"] != "scratch" { - t.Fatalf("valid override was lost: %v", paths) - } - - folder, ok := vault.FolderForRelativePathWithSettings("assets/image.png", paths) - if ok { - t.Fatalf("assets/image.png classified as %q; it is not a note folder", folder) - } - - // A deleted vault.json means no overrides, matching what the vault reports. - if err := os.Remove(filepath.Join(root, vaultSettingsFilePath)); err != nil { - t.Fatal(err) - } - w.reloadFolderPaths() - if got := w.getFolderPaths(); len(got) != 0 { - t.Fatalf("folder paths after vault.json removal = %v, want none", got) - } -} - -func TestWatcherClassifiesRemappedFolderEvents(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - writeVaultSettings(t, root, `{"systemFolderPaths":{"trash":"deleted"}}`) - w.reloadFolderPaths() - - ch, unsub := w.Subscribe() - defer unsub() - - note := filepath.Join(root, "deleted", "Gone.md") - if err := os.MkdirAll(filepath.Dir(note), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(note, []byte("gone"), 0o600); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: note, Op: fsnotify.Write}) - ev := recvChange(t, ch) - if ev.Folder != vault.FolderTrash || ev.Path != "deleted/Gone.md" { - t.Fatalf("event = %+v, want {change deleted/Gone.md trash}", ev) - } -} - -func TestWatcherDoesNotSurfaceInternalDirAsFolder(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - internal := filepath.Join(root, internalVaultDir) - if err := os.MkdirAll(internal, 0o700); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: internal, Op: fsnotify.Create}) - - select { - case ev := <-ch: - t.Fatalf("unexpected folder event for %s: %+v", internalVaultDir, ev) - case <-time.After(100 * time.Millisecond): - // Expected: .zennotes is not a user-facing folder. - } -} - -func TestActiveDistinguishesRealFromDisabledWatcher(t *testing.T) { - root := t.TempDir() - - disabled := Disabled(root) - defer disabled.Close() - if disabled.Active() { - t.Fatal("Disabled watcher reports Active; capabilities would promise live updates it cannot deliver") - } - - real, err := Start(root) - if err != nil { - t.Skipf("fsnotify unavailable here: %v", err) - } - defer real.Close() - if !real.Active() { - t.Fatal("real watcher reports inactive") - } - - var nilWatcher *Watcher - if nilWatcher.Active() { - t.Fatal("nil watcher reports Active") - } -} - -// Every atomic note save creates a scratch file next to the note and renames it -// into place. The scratch file is not a vault change, and because its name does -// not end in .md a client that heard about it would answer by re-listing the -// whole asset tree, on every save. -func TestWatcherIgnoresAtomicWriteScratchFiles(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - scratch := filepath.Join(root, "inbox", "note.md.4123.1786714355519123456.tmp") - for _, op := range []fsnotify.Op{fsnotify.Create, fsnotify.Write, fsnotify.Rename} { - w.handle(fsnotify.Event{Name: scratch, Op: op}) - } - - select { - case ev := <-ch: - t.Fatalf("a scratch file reached clients: %+v", ev) - case <-time.After(100 * time.Millisecond): - } - - // The note the scratch file was renamed onto still reports normally. - w.handle(fsnotify.Event{Name: filepath.Join(root, "inbox", "note.md"), Op: fsnotify.Create}) - if ev := recvChange(t, ch); ev.Path != "inbox/note.md" { - t.Fatalf("note event = %+v, want inbox/note.md", ev) - } -} - -// inotify reports a rename-into-place as IN_MOVED_TO, which fsnotify folds into -// Create, so an atomic write (ours, or git/rsync/vim/Syncthing doing the same -// dance) surfaces as "add" rather than "change". Clients therefore have to treat -// an "add" for a note they hold open as new content to read, and this test is -// what pins that contract down on the server side. -func TestWatcherReportsRenameIntoPlaceAsAdd(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - note := filepath.Join(root, "inbox", "note.md") - if err := os.MkdirAll(filepath.Dir(note), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(note, []byte("replaced by rename"), 0o600); err != nil { - t.Fatal(err) - } - - w.handle(fsnotify.Event{Name: note, Op: fsnotify.Create}) - ev := recvChange(t, ch) - if ev.Kind != "add" || ev.Path != "inbox/note.md" || ev.Scope != "" { - t.Fatalf("rename-into-place event = %+v, want {add inbox/note.md}", ev) - } -} - -// The kqueue backend (a server hosted on macOS) reports the rename half of an -// atomic save as a delete of the note itself, arriving just before the add. A -// client that believes it closes the tab of the note being saved, so a path -// that still exists must never be reported as gone. -func TestWatcherDoesNotReportAReplacedNoteAsDeleted(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - note := filepath.Join(root, "inbox", "note.md") - if err := os.MkdirAll(filepath.Dir(note), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(note, []byte("the replacement is already here"), 0o600); err != nil { - t.Fatal(err) - } - - w.handle(fsnotify.Event{Name: note, Op: fsnotify.Remove}) - if ev := recvChange(t, ch); ev.Kind == "unlink" { - t.Fatalf("a replaced note was reported as deleted: %+v", ev) - } - - // A note that really is gone still reports as gone. - if err := os.Remove(note); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: note, Op: fsnotify.Remove}) - if ev := recvChange(t, ch); ev.Kind != "unlink" { - t.Fatalf("deleted note event = %+v, want unlink", ev) - } -} - -func TestWatcherSurfacesTemplateChangesWithOwnScope(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - dir := filepath.Join(root, internalVaultDir, "templates") - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - file := filepath.Join(dir, "adr.md") - if err := os.WriteFile(file, []byte("---\nname: ADR\n---\n"), 0o600); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: file, Op: fsnotify.Write}) - ev := recvChange(t, ch) - if ev.Scope != "templates" || ev.Kind != "change" || ev.Path != ".zennotes/templates/adr.md" { - t.Fatalf("template write event = %+v", ev) - } - - if err := os.Remove(file); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: file, Op: fsnotify.Remove}) - ev = recvChange(t, ch) - if ev.Scope != "templates" || ev.Kind != "unlink" { - t.Fatalf("template remove event = %+v", ev) - } -} - -func TestWatcherIgnoresNonTemplatesUnderTemplatesDir(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - ch, unsub := w.Subscribe() - defer unsub() - - dir := filepath.Join(root, internalVaultDir, "templates") - if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o700); err != nil { - t.Fatal(err) - } - for _, name := range []string{".draft.md", "notes.txt", filepath.Join("nested", "x.md")} { - file := filepath.Join(dir, name) - if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: file, Op: fsnotify.Write}) - } - select { - case ev := <-ch: - t.Fatalf("unexpected event for a non-template: %+v", ev) - case <-time.After(100 * time.Millisecond): - // Expected: dotfiles, other extensions and nested paths are not templates. - } -} - -func TestWatcherWatchesDirectoriesCreatedWithTheirParent(t *testing.T) { - root := t.TempDir() - w := newTestWatcher(t, root) - _, unsub := w.Subscribe() - defer unsub() - - // .zennotes/ and .zennotes/templates/ arrive together (one MkdirAll); the - // watcher hears one Create for the parent. - internal := filepath.Join(root, internalVaultDir) - templates := filepath.Join(internal, "templates") - if err := os.MkdirAll(templates, 0o700); err != nil { - t.Fatal(err) - } - w.handle(fsnotify.Event{Name: internal, Op: fsnotify.Create}) - if _, ok := w.dirs[internal]; !ok { - t.Fatalf("parent directory not tracked") - } - if _, ok := w.dirs[templates]; !ok { - t.Fatalf("child directory created with its parent is not watched") - } -} diff --git a/apps/server/package.json b/apps/server/package.json deleted file mode 100644 index 9a5f860a..00000000 --- a/apps/server/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@zennotes/server", - "private": true, - "version": "2.50.4", - "scripts": { - "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", - "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", - "typecheck": "node ../../tooling/scripts/run-go-server-test.mjs", - "test": "node ../../tooling/scripts/run-go-server-test.mjs", - "test:run": "node ../../tooling/scripts/run-go-server-test.mjs", - "sync-web": "node ../../tooling/scripts/sync-web-dist.mjs", - "build": "node ../../tooling/scripts/build-go-server.mjs" - } -} diff --git a/apps/server/web/embed.go b/apps/server/web/embed.go deleted file mode 100644 index 0fedd582..00000000 --- a/apps/server/web/embed.go +++ /dev/null @@ -1,16 +0,0 @@ -package web - -import ( - "embed" - "io/fs" -) - -//go:embed all:dist -var dist embed.FS - -// Dist returns the embedded PWA bundle rooted at `dist/`. When the -// client bundle has not been built yet, the subtree is empty and the -// caller should fall back to proxying to Vite dev in development. -func Dist() (fs.FS, error) { - return fs.Sub(dist, "dist") -} diff --git a/apps/share-viewer/README.md b/apps/share-viewer/README.md new file mode 100644 index 00000000..cfd02504 --- /dev/null +++ b/apps/share-viewer/README.md @@ -0,0 +1,48 @@ +# Public share viewer + +This is the maintained read-only renderer embedded by Laravel's public share +pages. Its source was recovered from `c534a1d0`, then updated for current core APIs, +shared styles, published themes, and graceful fallback. The recovered historical +build was not byte-identical to the previously copied website bundle; see the +ecosystem plan's provenance record. This is a new, independently verified build. + +## Build and package + +```sh +npm ci +npm run build --workspace @zennotes/share-viewer +npm run pack:share-viewer +``` + +The producer emits an immutable archive and JSON manifest in +`dist/viewer-artifacts`. The manifest records the `share-page-payload-v1` protocol, +source commit/dirty state, lockfile hash, toolchain, entrypoints, and every asset's +size and checksum. Changing any payload bytes creates a new version; reusing a +version with different bytes fails. This viewer is separate from the self-hosted +web application's login/editor artifact. + +`share-viewer.js` and `share-viewer.css` have stable names inside a versioned asset +directory. Chunks/fonts are relative to that directory. Laravel pins the manifest, +verifies and imports the archive without a frontend source checkout, and retains +previous versions for rollback. Candidate CI builds artifacts; it does not publish +or deploy them. Clean-source release publication and Laravel's production build +configuration remain approval gates. + +## Payload and fallback + +Laravel owns `#zen-share-data`: title, exact Markdown, public asset URL mapping, +pre-rendered TikZ SVG mapping, appearance, and timestamps. Assets are resolved only +from that mapping; TikZ SVGs are sanitized. Private links and task mutations stay +inert. Copy, external links, Mermaid, sanitized TikZ, and Markdown formatting remain. +JSXGraph and function-plot renderers are excluded from this public bundle because +their configuration can introduce executable expressions or unsanitized HTML. +Those fences retain source access and explain that rendering is unavailable. + +The page supplies escaped Markdown in `.zen-share-fallback` under +`#zen-share-root`. It remains visible until the renderer completes. Invalid JSON, +missing JavaScript, or module load errors leave the fallback available. The viewer +preserves publication/note themes and follows the OS when appearance is `system`. + +The Laravel repository owns browser integration tests using actual generated +share/publication HTML and attachment responses. Run those tests before updating +its deployment pin; a successful standalone Vite build is insufficient. diff --git a/apps/share-viewer/index.html b/apps/share-viewer/index.html new file mode 100644 index 00000000..c6e3f53b --- /dev/null +++ b/apps/share-viewer/index.html @@ -0,0 +1,26 @@ + + + + + + ZenNotes Share Viewer; dev harness + + + + +
Loading…
+ + + diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json new file mode 100644 index 00000000..a4d787ae --- /dev/null +++ b/apps/share-viewer/package.json @@ -0,0 +1,65 @@ +{ + "name": "@zennotes/share-viewer", + "private": true, + "version": "2.51.0", + "type": "module", + "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", + "homepage": "https://zennotes.org", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "build:nocheck": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-vim": "^6.3.0", + "codemirror": "^6.0.1", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "gray-matter": "^4.0.3", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.4.3" + } +} diff --git a/apps/share-viewer/postcss.config.js b/apps/share-viewer/postcss.config.js new file mode 100644 index 00000000..2b75bd8a --- /dev/null +++ b/apps/share-viewer/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/apps/share-viewer/src/disabled-diagrams.ts b/apps/share-viewer/src/disabled-diagrams.ts new file mode 100644 index 00000000..04440f15 --- /dev/null +++ b/apps/share-viewer/src/disabled-diagrams.ts @@ -0,0 +1,8 @@ +/** Public publishers are untrusted. These libraries accept executable expressions + * or unsanitized HTML after the Markdown sanitizer has already run. Keep them out + * of the public bundle until a separate validated rendering contract exists. */ +function unavailable(): never { + throw new Error('Interactive plot rendering is unavailable on public shares. Use the source button to read the diagram code.') +} +export const JSXGraph = { initBoard: unavailable } +export default Object.assign(unavailable, { JSXGraph }) diff --git a/apps/share-viewer/src/env.d.ts b/apps/share-viewer/src/env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/apps/share-viewer/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/share-viewer/src/main.tsx b/apps/share-viewer/src/main.tsx new file mode 100644 index 00000000..6206c3c0 --- /dev/null +++ b/apps/share-viewer/src/main.tsx @@ -0,0 +1,176 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import type { AssetMeta, ImportedAssetKind, NoteContent, VaultInfo } from '@shared/ipc' +import { readSharePagePayload, type SharePagePayload } from './payload' +import { THEMES } from '@renderer/lib/themes' +import { installShareViewerBridge } from './shim' +// The full app stylesheet (prose, themes, KaTeX, highlight, diagram +// chrome); the same file the PDF export window ships wholesale. +import '@renderer/styles/index.css' + +const payload = readSharePagePayload() +if (payload) { + // The bridge must exist before any app-core module runs. + installShareViewerBridge(payload) + void boot(payload).catch(error => console.error('Share viewer could not load; keeping the Markdown fallback.', error)) +} else { + console.error('zen-share-data payload missing or malformed; leaving fallback markup in place.') +} + +async function boot(data: SharePagePayload): Promise { + applyTheme(data) + + // Imported lazily so the shim is installed before app-core touches + // window.zen, and so the store never boots on malformed pages. + const [{ useStore }, { LazyPreview }] = await Promise.all([ + import('@renderer/store'), + import('@renderer/components/LazyPreview') + ]) + + const notePath = 'shared-note.md' + const note: NoteContent = { + path: notePath, + title: data.title, + folder: 'inbox', + siblingOrder: 0, + createdAt: data.published_at ? Date.parse(data.published_at) : Date.now(), + updatedAt: data.updated_at ? Date.parse(data.updated_at) : Date.now(), + size: data.markdown.length, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: Object.keys(data.assets).length > 0, + excerpt: '', + body: data.markdown + } + + // Asset refs double as vault-relative paths: the publisher uploaded + // each asset under the literal markdown ref, so an identity mapping + // makes app-core's resolver land on exactly those keys. + const assetFiles: AssetMeta[] = Object.keys(data.assets).map((ref, index) => ({ + path: ref, + name: ref.split('/').pop() ?? ref, + kind: assetKindOf(ref), + siblingOrder: index, + size: 0, + updatedAt: 0 + })) + + useStore.setState({ + vault: { root: '/shared', name: 'Shared note' } satisfies VaultInfo, + notes: [], + assetFiles, + selectedPath: notePath, + activeNote: note + }) + + const root = document.getElementById('zen-share-root') + if (!root) return + const fallback = root.querySelector('.zen-share-fallback') + const rendered = document.createElement('div') + rendered.className = 'zen-share-rendering' + root.appendChild(rendered) + // The public page allows ordinary links, selection, copy and diagram tools. + // Stop native app navigation/mutations before Preview's event handlers run. + root.addEventListener('click', event => { + const target = event.target instanceof Element ? event.target : null + if (target?.closest('input[type="checkbox"], .zen-task-state-in-progress[data-task-index]')) { + event.preventDefault(); event.stopPropagation(); return + } + const anchor = target?.closest('a') + if (!anchor) return + event.stopPropagation() + const href = anchor.getAttribute('href') ?? '' + if (anchor.matches('.wikilink, .hashtag') || !/^(https?:|mailto:|#)/i.test(href)) event.preventDefault() + else if (!href.startsWith('#')) { anchor.target = '_blank'; anchor.rel = 'noopener noreferrer' } + }, true) + root.addEventListener('change', event => event.stopPropagation(), true) + root.addEventListener('contextmenu', event => event.stopPropagation(), true) + const onRendered = (): void => { + neutralizeAppOnlyInteractions() + rendered.classList.remove('zen-share-rendering') + if (fallback) fallback.hidden = true + root.dataset.viewerReady = 'true' + } + + ReactDOM.createRoot(rendered).render( + +
+ +
+
+ ) +} + +/** Respect the published theme; system follows the viewer's OS preference. */ +function applyTheme(data: SharePagePayload): void { + const media = window.matchMedia('(prefers-color-scheme: dark)') + const apply = (): void => { + const html = document.documentElement + const theme = THEMES.find(theme => theme.id === data.appearance.theme) + ?? THEMES.find(theme => theme.id === (media.matches ? 'github-dark' : 'github-light'))! + html.dataset.theme = theme.id + html.dataset.themeMode = theme.mode + html.setAttribute('data-opaque', '') + html.style.colorScheme = theme.mode + } + apply() + media.addEventListener('change', apply) + + const style = document.createElement('style') + style.textContent = ` + /* The app stylesheet treats the document as a fixed-viewport app + shell (height: 100%, overflow: hidden, user-select: none). A + public page is a normal scrolling document; undo all three, + same as the PDF export window does. */ + html, body { + height: auto !important; + min-height: 100vh; + margin: 0; + overflow: visible !important; + user-select: text !important; + background: rgb(var(--z-bg)); + } + #zen-share-root { position: relative; } + .zen-share-rendering { position: absolute; visibility: hidden; pointer-events: none; width: 100%; } + .zen-share-note { padding: 8px 0 48px; } + .zen-share-note .prose-zen a.wikilink, + .zen-share-note .prose-zen a.wikilink.broken, + .zen-share-note .prose-zen a.hashtag { + color: rgb(var(--z-grey-1)); + border-bottom: 1px dashed rgb(var(--z-grey-dim)); + text-decoration: none; + pointer-events: none; + cursor: default; + } + .zen-share-note .prose-zen input[type="checkbox"] { + pointer-events: none; + } + ` + document.head.appendChild(style) +} + +/** + * Wikilinks, hashtags, and task checkboxes act on the vault in-app; on + * a public page they are inert text. CSS removes the affordances; this + * pass drops the zen:// hrefs and locks checkboxes for good measure. + */ +function neutralizeAppOnlyInteractions(): void { + const root = document.getElementById('zen-share-root') + if (!root) return + for (const anchor of root.querySelectorAll('a.wikilink, a.hashtag')) { + anchor.removeAttribute('href') + } + for (const checkbox of root.querySelectorAll('input[type="checkbox"]')) { + checkbox.disabled = true + } +} + +function assetKindOf(ref: string): ImportedAssetKind { + const ext = ref.toLowerCase().split('.').pop() ?? '' + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'apng'].includes(ext)) return 'image' + if (ext === 'pdf') return 'pdf' + if (['mp3', 'm4a', 'aac', 'flac', 'ogg', 'wav'].includes(ext)) return 'audio' + if (['mp4', 'm4v', 'mov', 'ogv', 'webm'].includes(ext)) return 'video' + return 'file' +} diff --git a/apps/share-viewer/src/payload.ts b/apps/share-viewer/src/payload.ts new file mode 100644 index 00000000..0f6eba35 --- /dev/null +++ b/apps/share-viewer/src/payload.ts @@ -0,0 +1,38 @@ +/** The JSON document the Laravel share page embeds in #zen-share-data. */ +export interface SharePagePayload { + title: string + markdown: string + /** Markdown ref (decoded) → absolute public URL. */ + assets: Record + /** sha1(raw tikz fence body) → pre-rendered SVG. */ + tikz: Record + appearance: { theme: string; logo_url: string | null } + published_at: string | null + updated_at: string | null +} + +export function readSharePagePayload(): SharePagePayload | null { + const el = document.getElementById('zen-share-data') + if (!el?.textContent) return null + try { + const parsed = JSON.parse(el.textContent) as Partial + if (typeof parsed.markdown !== 'string') return null + return { + title: typeof parsed.title === 'string' ? parsed.title : 'Untitled', + markdown: parsed.markdown, + assets: isStringRecord(parsed.assets) ? parsed.assets : {}, + tikz: isStringRecord(parsed.tikz) ? parsed.tikz : {}, + appearance: { theme: typeof parsed.appearance?.theme === 'string' ? parsed.appearance.theme : 'system', + logo_url: typeof parsed.appearance?.logo_url === 'string' ? parsed.appearance.logo_url : null }, + published_at: typeof parsed.published_at === 'string' ? parsed.published_at : null, + updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : null + } + } catch { + return null + } +} + +function isStringRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + return Object.values(value).every((entry) => typeof entry === 'string') +} diff --git a/apps/share-viewer/src/shim.ts b/apps/share-viewer/src/shim.ts new file mode 100644 index 00000000..855539a8 --- /dev/null +++ b/apps/share-viewer/src/shim.ts @@ -0,0 +1,168 @@ +import DOMPurify from 'dompurify' +import type { ZenAppInfo, ZenBridge, ZenCapabilities } from '@bridge-contract/bridge' +import type { TikzRenderResponse } from '@shared/ipc' +import appPackage from '../package.json' +import type { SharePagePayload } from './payload' + +const VIEWER_CAPABILITIES: ZenCapabilities = { + supportsUpdater: false, + supportsNativeMenus: false, + supportsFloatingWindows: false, + supportsLocalFilesystemPickers: false, + supportsRemoteWorkspace: false, + supportsCliInstall: false, + supportsCustomTemplates: false, + supportsCloudSync: false, + supportsCustomCodeLanguages: false +} + +const VIEWER_APP_INFO: ZenAppInfo = { + name: 'zennotes-share-viewer', + productName: 'ZenNotes', + version: appPackage.version, + description: 'Read-only viewer for shared ZenNotes', + homepage: 'https://zennotes.org', + runtime: 'web' +} + +/** + * sha1 hex matching Node's createHash('sha1') output. WebCrypto when + * available; plain-JS fallback because crypto.subtle only exists in + * secure contexts and local dev serves over plain http (zennotes.test). + */ +async function sha1Hex(input: string): Promise { + if (typeof crypto !== 'undefined' && crypto.subtle) { + const digest = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(input)) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('') + } + return sha1HexSync(input) +} + +function sha1HexSync(input: string): string { + const bytes = new TextEncoder().encode(input) + const byteLength = bytes.length + const totalLength = Math.ceil((byteLength + 9) / 64) * 64 + const padded = new Uint8Array(totalLength) + padded.set(bytes) + padded[byteLength] = 0x80 + const view = new DataView(padded.buffer) + view.setUint32(totalLength - 8, Math.floor((byteLength * 8) / 0x100000000)) + view.setUint32(totalLength - 4, (byteLength * 8) >>> 0) + + let h0 = 0x67452301 + let h1 = 0xefcdab89 + let h2 = 0x98badcfe + let h3 = 0x10325476 + let h4 = 0xc3d2e1f0 + const words = new Uint32Array(80) + const rotl = (x: number, n: number): number => ((x << n) | (x >>> (32 - n))) >>> 0 + + for (let offset = 0; offset < totalLength; offset += 64) { + for (let i = 0; i < 16; i += 1) words[i] = view.getUint32(offset + i * 4) + for (let i = 16; i < 80; i += 1) { + words[i] = rotl(words[i - 3]! ^ words[i - 8]! ^ words[i - 14]! ^ words[i - 16]!, 1) + } + let a = h0 + let b = h1 + let c = h2 + let d = h3 + let e = h4 + for (let i = 0; i < 80; i += 1) { + let f: number + let k: number + if (i < 20) { + f = (b & c) | (~b & d) + k = 0x5a827999 + } else if (i < 40) { + f = b ^ c ^ d + k = 0x6ed9eba1 + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d) + k = 0x8f1bbcdc + } else { + f = b ^ c ^ d + k = 0xca62c1d6 + } + const next = (rotl(a, 5) + (f >>> 0) + e + k + words[i]!) >>> 0 + e = d + d = c + c = rotl(b, 30) + b = a + a = next + } + h0 = (h0 + a) >>> 0 + h1 = (h1 + b) >>> 0 + h2 = (h2 + c) >>> 0 + h3 = (h3 + d) >>> 0 + h4 = (h4 + e) >>> 0 + } + + return [h0, h1, h2, h3, h4].map((part) => part.toString(16).padStart(8, '0')).join('') +} + +function decodeRef(href: string): string { + const cleaned = href.split('#')[0]?.split('?')[0] ?? href + try { + return decodeURIComponent(cleaned) + } catch { + return cleaned + } +} + +function lookupAsset(payload: SharePagePayload, href: string): string | null { + const direct = Object.hasOwn(payload.assets, href) ? payload.assets[href] : null + if (direct && /^https?:\/\//i.test(direct)) return direct + const ref = decodeRef(href) + const decoded = Object.hasOwn(payload.assets, ref) ? payload.assets[ref] : null + const value = decoded ?? null + return value && /^https?:\/\//i.test(value) ? value : null +} + +/** + * Install a minimal `window.zen` so app-core's Preview pipeline renders + * a shared note exactly like the app does: + * + * - `renderTikz` substitutes the pre-rendered (and sanitized) SVG the + * publisher uploaded, keyed by sha1 of the fence body. + * - asset URL resolution maps markdown refs onto the share's public + * asset URLs. + * - everything else is inert; this is a read-only page. + */ +export function installShareViewerBridge(payload: SharePagePayload): void { + const overrides: Partial = { + getCapabilities: () => VIEWER_CAPABILITIES, + getConfigSync: () => null, + getAppInfo: () => VIEWER_APP_INFO, + platformSync: () => 'linux' as const, + platform: async () => 'linux' as const, + listCustomCodeLanguages: async () => [], + readWorkspaceState: async () => null, + + renderTikz: async (source: string): Promise => { + const svg = payload.tikz[await sha1Hex(source)] + if (!svg) { + return { ok: false, error: 'This TikZ diagram is not available on the shared page.' } + } + const sanitized = DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true } + }) + return { ok: true, svg: sanitized } + }, + + resolveVaultAssetUrl: (_vaultRoot: string, assetPath: string): string | null => + lookupAsset(payload, assetPath), + resolveLocalAssetUrl: (_vaultRoot: string, _notePath: string, href: string): string | null => + lookupAsset(payload, href), + getPathForFile: () => null, + + clipboardWriteText: (text: string): void => { + void navigator.clipboard?.writeText(text) + }, + clipboardReadText: (): string => '' + } + + // Only the Preview read/copy surface exists. An unsupported mutation must + // fail instead of claiming that a write to a public share succeeded. + window.zen = Object.freeze(overrides) as ZenBridge + +} diff --git a/apps/share-viewer/tailwind.config.js b/apps/share-viewer/tailwind.config.js new file mode 100644 index 00000000..3fc05146 --- /dev/null +++ b/apps/share-viewer/tailwind.config.js @@ -0,0 +1,2 @@ +import preset from '../../packages/app-core/build/tailwind-preset.cjs' +export default { ...preset, content: ['./index.html', './src/**/*.{ts,tsx}', '../../packages/app-core/src/**/*.{ts,tsx}'] } diff --git a/apps/share-viewer/tsconfig.json b/apps/share-viewer/tsconfig.json new file mode 100644 index 00000000..eb0fa1d9 --- /dev/null +++ b/apps/share-viewer/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "useDefineForClassFields": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@renderer/*": ["../../packages/app-core/src/*"], + "@shared/*": ["../../packages/shared-domain/src/*"], + "@bridge-contract/*": ["../../packages/bridge-contract/src/*"], + "@zennotes/app-core/*": ["../../packages/app-core/src/*"], + "@zennotes/bridge-contract/*": ["../../packages/bridge-contract/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/share-viewer/vite.config.ts b/apps/share-viewer/vite.config.ts new file mode 100644 index 00000000..d590d805 --- /dev/null +++ b/apps/share-viewer/vite.config.ts @@ -0,0 +1,45 @@ +import { resolve } from 'node:path' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { zenNotesAssets } from '../../packages/app-core/build/vite.mjs' + +// The Laravel share page references exactly two stable filenames - +// share-viewer.js and share-viewer.css (cache-busted by ?v=). Lazy +// chunks keep content hashes and load relative to the entry module. +export default defineConfig({ + root: __dirname, + base: './', + resolve: { + dedupe: ['react', 'react-dom'], + alias: [ + { find: /^(jsxgraph|function-plot)$/, replacement: resolve(__dirname, 'src/disabled-diagrams.ts') }, + { find: '@renderer', replacement: resolve(__dirname, '../../packages/app-core/src') }, + { find: '@shared', replacement: resolve(__dirname, '../../packages/shared-domain/src') }, + { + find: '@bridge-contract', + replacement: resolve(__dirname, '../../packages/bridge-contract/src') + } + ] + }, + server: { + port: 5179 + }, + plugins: [react(), zenNotesAssets({ harper: false })], + build: { + outDir: 'dist', + emptyOutDir: true, + chunkSizeWarningLimit: 3500, + sourcemap: false, + // One stylesheet for the whole viewer (lazy chunks included) so the + // Blade page only ever links share-viewer.css. + cssCodeSplit: false, + rollupOptions: { + output: { + entryFileNames: 'share-viewer.js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: (info) => + info.name?.endsWith('.css') ? 'share-viewer.css' : 'assets/[name]-[hash][extname]' + } + } + } +}) diff --git a/apps/web/index.html b/apps/web/index.html index ea2a2f5e..7a2de619 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -20,14 +20,5 @@
- diff --git a/apps/web/package.json b/apps/web/package.json index 7c223b35..43d359dd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.50.4", + "version": "2.51.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index eec10d46..3381fc18 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -106,7 +106,8 @@ const WEB_APP_INFO: ZenAppInfo = { version: appPackage.version, description: appPackage.description, homepage: appPackage.homepage, - runtime: 'web' + runtime: 'web', + hostKind: 'browser' } // Base path under which the server is mounted (e.g. "/zennotes" when diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8e349886..b7080ceb 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,8 +1,10 @@ import { renderZenNotesApp } from '@zennotes/app-core/main' import { installBridge, webBasePath } from './bridge/http-bridge' import { renderExportNoteWindow } from './export-window' +import { registerServiceWorker } from './register-service-worker' installBridge() +registerServiceWorker() // Point Excalidraw's font loader at our same-origin, CSP-allowed path instead of // its default esm.sh CDN, which the server's `font-src 'self'` blocks so font diff --git a/apps/web/src/register-service-worker.ts b/apps/web/src/register-service-worker.ts new file mode 100644 index 00000000..49919535 --- /dev/null +++ b/apps/web/src/register-service-worker.ts @@ -0,0 +1,12 @@ +// The self-hosted server answers with a Content Security Policy of +// `script-src 'self'`, which blocks inline scripts. Registering from the +// bundle keeps the worker inside that policy; an inline registration in +// index.html was silently refused, so production deployments never got sw.js. +// The relative URL keeps the scope aligned with a prefixed deployment: +// /zennotes/sw.js registers for /zennotes/. +export function registerServiceWorker(): void { + if (!('serviceWorker' in navigator) || window.location.protocol === 'file:') return + window.addEventListener('load', () => { + navigator.serviceWorker.register('sw.js').catch(() => {}) + }) +} diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 31b20915..365de1ee 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -1,86 +1,7 @@ +import preset from '../../packages/app-core/build/tailwind-preset.cjs' + /** @type {import('tailwindcss').Config} */ export default { - content: ['./index.html', './src/**/*.{ts,tsx}', '../../packages/app-core/src/**/*.{ts,tsx}'], - theme: { - extend: { - colors: { - paper: { - 50: 'rgb(var(--z-bg-softer) / )', - 100: 'rgb(var(--z-bg) / )', - 200: 'rgb(var(--z-bg-1) / )', - 300: 'rgb(var(--z-bg-2) / )', - 400: 'rgb(var(--z-bg-3) / )', - 500: 'rgb(var(--z-bg-4) / )' - }, - ink: { - 900: 'rgb(var(--z-fg) / )', - 800: 'rgb(var(--z-fg-1) / )', - 700: 'rgb(var(--z-fg-2) / )', - 600: 'rgb(var(--z-grey-2) / )', - 500: 'rgb(var(--z-grey-1) / )', - 400: 'rgb(var(--z-grey-0) / )', - 300: 'rgb(var(--z-grey-dim) / )' - }, - accent: { - DEFAULT: 'rgb(var(--z-accent) / )', - soft: 'rgb(var(--z-accent-soft) / )', - muted: 'rgb(var(--z-accent-muted) / )' - }, - danger: 'rgb(var(--z-red) / )', - success: 'rgb(var(--z-green) / )', - warning: 'rgb(var(--z-yellow) / )' - }, - borderRadius: { - // Scale every rounded-* by --z-radius-scale (default 1) so one var can - // square all corners (Quick tweaks → Square corners sets it to 0). - // rounded-none / rounded-full keep Tailwind defaults, so pills and - // circles stay round. - DEFAULT: 'calc(0.25rem * var(--z-radius-scale, 1))', - sm: 'calc(0.125rem * var(--z-radius-scale, 1))', - md: 'calc(0.375rem * var(--z-radius-scale, 1))', - lg: 'calc(0.5rem * var(--z-radius-scale, 1))', - xl: 'calc(0.75rem * var(--z-radius-scale, 1))', - '2xl': 'calc(1rem * var(--z-radius-scale, 1))', - '3xl': 'calc(1.5rem * var(--z-radius-scale, 1))' - }, - fontFamily: { - sans: [ - '-apple-system', - 'BlinkMacSystemFont', - '"SF Pro Text"', - '"Inter"', - 'system-ui', - 'sans-serif' - ], - serif: ['"Iowan Old Style"', '"Source Serif Pro"', 'Georgia', 'serif'], - mono: ['"JetBrains Mono"', '"SF Mono"', 'Menlo', 'monospace'] - }, - boxShadow: { - panel: - '0 1px 0 0 rgb(var(--z-shadow) / 0.04), 0 8px 28px -12px rgb(var(--z-shadow) / 0.18)', - float: '0 20px 60px -20px rgb(var(--z-shadow) / 0.28)' - }, - fontSize: { - '2xs': ['0.6875rem', { lineHeight: '1rem' }] - }, - zIndex: { - dropdown: '40', - palette: '50', - modal: '70', - nested: '75', - popover: '80', - toast: '90' - }, - maxWidth: { - 'dialog-xs': '420px', - 'dialog-sm': '440px', - 'dialog-md': '560px', - 'dialog-lg': '720px', - 'dialog-xl': '900px', - 'dialog-2xl': '1120px', - 'dialog-3xl': '1360px' - } - } - }, - plugins: [] + presets: [preset], + content: ['./index.html', './src/**/*.{ts,tsx}', '../../packages/app-core/src/**/*.{ts,tsx}'] } diff --git a/docker-compose.yml b/docker-compose.yml index 2537a962..8a49b989 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,8 @@ +# The image is published from ZenNotes/znserver (the Go server's repository); +# nothing here builds it. Pin a version with ZENNOTES_IMAGE or `make up IMAGE=...`. services: zennotes: - build: - context: . - dockerfile: Dockerfile - image: "${ZENNOTES_IMAGE:-zennotes-selfhosted:local}" + image: "${ZENNOTES_IMAGE:-adibhanna/zennotes:latest}" container_name: zennotes-selfhosted restart: unless-stopped user: "${ZENNOTES_CONTAINER_UID:-1000}:${ZENNOTES_CONTAINER_GID:-1000}" diff --git a/docs/agent-handoff-2026-09-16.md b/docs/agent-handoff-2026-09-16.md new file mode 100644 index 00000000..6fcc81b2 --- /dev/null +++ b/docs/agent-handoff-2026-09-16.md @@ -0,0 +1,528 @@ +# ZenNotes ecosystem, CLI migration, and bug-fix handoff + +Prepared September 16, 2026. This document records the work in this task and the state checked at handoff. Read this before changing, committing, publishing, or replacing any working tree. + +## 1. Start here + +The work has three different publication states: + +1. Desktop v2.50.3 was released, with website updates and demo videos. The user's subsequent v2.50.4 release was incorporated into the boundary work. +2. Five approved ecosystem-boundary snapshots were committed and pushed to draft PRs in separate worktrees. They remain open and have failed CI checks that need investigation. +3. Later CLI migration work, fixes for desktop issues #790, #791, and #792, and TUI/website usability changes are still local in the original working directories. They are newer than those PR snapshots. The issues were commented on and closed at the user's explicit request, with comments stating that the code is unpublished. + +The compatible Go CLI release is prepared but unpublished. Desktop's production Go release pin is intentionally null. The server repository is still empty. The full ecosystem cutover is therefore not complete. + +### Non-negotiable preservation and approval rules + +- The user's global rule is: **Never commit or push code without explicitly asking first and receiving approval.** Prior approval covered the five boundary PR snapshots. Do not reuse it for later changes. +- Do useful local implementation and verification before asking for publication approval. The user should approve a concrete scope. +- Do not reset, clean, stash away, or blindly pull over the original dirty trees. Preserve the existing indexes and unrelated edits. +- Desktop has 23 previously staged files, 570 insertions and 487 deletions. Its working tree includes far more unstaged and untracked work. +- The original desktop HEAD is older than v2.50.4, but its files already contain the reconciled v2.50.4 fixes. A simple HEAD comparison does not describe its contents. +- iOS has an unrelated local Xcode version/build bump, 1.9.10/build 21, intentionally excluded from the boundary PR. +- Use isolated HOME, config, user-data, and vault paths for runtime tests. Do not use personal Cloud accounts or vaults. Packaged user-data overrides require `ZEN_PERF=1`. +- Keep local candidate artifacts separate from production release pins. Do not weaken provenance checks to make CI pass. +- Read applicable repository instructions before edits. The Laravel repository has its own AGENTS.md. The desktop house style avoids em dashes in new code/docs. +- No new Git commits, pushes, releases, deployments, or GitHub comments were made while preparing this handoff. + +## 2. Working directories and snapshots + +### Original working copies: latest local work lives here + +| Component | Absolute path | Branch | HEAD at handoff | +| --- | --- | --- | --- | +| Desktop/web/shared packages | `/Users/adibhanna/Developer/opensource/zennotes` | `refactor/ecosystem-boundaries` | `a8fc4fc9a954c107b2fe4d6a4433c53702b01b13` | +| TUI/Go CLI | `/Users/adibhanna/Developer/opensource/zennotescli` | `main` | `3ccdc81547780eeee325eb1e4b8b09dc85d17f28` | +| Android | `/Users/adibhanna/Developer/apps/zennotesandroid` | `main` | `50c31dcb6ad7799f146cbe45ec6627181cda562f` | +| iOS | `/Users/adibhanna/Developer/apps/zennotesiphone` | `fix/cloud-live-regressions` | `9971018286cd371f90d7afb882e46d8be3afde50` | +| Laravel website/Cloud | `/Users/adibhanna/Developer/Laravel/zennotes` | `main` | `76a2e20ec7b58cf741ea76b29486558e8880d92d` | +| Homebrew tap | `/Users/adibhanna/Developer/opensource/homebrew-tap` | `main` | `ae257ff3faf6fd3883d03b6205f5386267c91ef2` | + +The Homebrew checkout was clean. The application checkouts contain local work. + +Fresh machine-readable snapshots: + +- [Repository branches, HEADs, statuses, and staged stats](/Users/adibhanna/Developer/opensource/zennotes/dist/handoff-2026-09-16/repository-state.json) +- [Live PR states and check results](/Users/adibhanna/Developer/opensource/zennotes/dist/handoff-2026-09-16/pull-request-state.json) + +These snapshots are inventories, not copies of all file contents. + +### Separate approved PR worktrees + +Root: `/Users/adibhanna/Developer/worktrees/zennotes-boundaries-pr-xvdswfdx` + +Subdirectories: `main`, `android`, `ios`, `laravel`, `tui`. Each uses branch `refactor/ecosystem-boundaries-pr` in its own repository. + +The root contains `README.md`, `manifest.json`, `verification.json`, and `backups/`. The manifest records source paths, base hashes, original index information, file hashes, and backups. Use these to distinguish the approved snapshot from later local edits. + +Do not copy the old PR worktrees back over the originals. Reconcile later changes carefully against the recorded snapshots and current remote bases. + +### Draft PRs and fresh CI status + +| Component | PR | Head | Base | State checked September 16 | +| --- | --- | --- | --- | --- | +| Desktop/web/boundaries | [zennotes #789](https://github.com/ZenNotes/zennotes/pull/789) | `819f4fbb85ce50d9fb4460d81dd4ac4012b372c1` | `main` | Open draft, BLOCKED | +| Android | [zennotesandroid #66](https://github.com/ZenNotes/zennotesandroid/pull/66) | `a8ac7d18c8867feab3213e43f22628d5c85b2370` | `main` | Open draft, UNSTABLE | +| iOS | [zennotesios #23](https://github.com/ZenNotes/zennotesios/pull/23) | `e53fab7d5f66e3f0c4a8a91a55820d340ea4f923` | `fix/cloud-live-regressions` | Open draft, UNSTABLE | +| Website/Cloud | [website #25](https://github.com/ZenNotes/website/pull/25) | `be88655cebb989cca166b033b0d42b88bffa9f44` | `main` | Open draft, UNSTABLE | +| TUI | [tui #2](https://github.com/ZenNotes/tui/pull/2) | `91520ff86b8fabec67124bb5cd20a6beb3b89dd6` | `main` | Open draft, UNSTABLE | + +iOS #23 is stacked on the existing [Cloud fixes PR #22](https://github.com/ZenNotes/zennotesios/pull/22). Preserve that base relationship until its dependency is merged or deliberately rebased. + +Current check failures: + +- Desktop: all four `Build` jobs fail, on Ubuntu x64, Ubuntu arm64, macOS, and Windows. Go without frontend dependencies, viewer build, web candidate/browser checks, production dependency audit, and Nix server checks pass. JavaScript/TypeScript CodeQL analysis passes, but the aggregate `CodeQL` PR status reports failure. Investigate that distinction. +- Android: TypeScript/package-boundary/Android-build job fails; emulator launch is skipped. +- iOS: TypeScript/package-boundary/iOS-build job fails. +- Website: PHP 8.4 and 8.5 CI jobs fail; quality passes; deployment is skipped. A published clean viewer artifact is a known prerequisite for the normal installation gate. Do not assume this explains every failure without reading logs. +- TUI: Linux/macOS Go jobs and Homebrew checks pass; Windows Go job fails. + +The handoff pass checked statuses, not failure root causes. Historical local test results below do not mean GitHub CI is green. + +Useful run IDs: desktop CI `35042872379`; Android `35042877819`; iOS `35042909298`; website tests `35042931054`; TUI `35042936230`. Inspect with `gh run view --repo --log-failed`. + +`ZenNotes/znserver` was checked and is empty, with no base branch and no PR. Extraction preparation remains in the desktop boundary work. + +## 3. Previously shipped desktop release: v2.50.3 + +[GitHub release v2.50.3](https://github.com/ZenNotes/zennotes/releases/tag/v2.50.3) was published September 15, 2026 at 15:32 UTC. + +- Release commit: `cbd4d84a65679c9f909ff752fc83b30212f38d59`. +- macOS, Windows, and Linux installers were published. +- Website release content and videos were published; website release commit `652dbb19b4891f05d421401fb3b108cb4582b2da`. +- Homebrew, AUR, Nix packaging, and Docker were updated and verified as recorded in the release pack. + +### Changes shipped + +- **Optional window title bar, issue #754.** Settings > Appearance > Chrome can hide the main title row and controls while preserving tabs/sidebar/editor. Applies across vault windows and persists in config.toml. Particularly useful for Linux tiling window managers such as Hyprland. +- **External application links, issue #764.** Allowlisted URL schemes such as Zotero, Obsidian, and VS Code. Settings > Editor > Links configures schemes. Disabled schemes guide the user to settings; launch errors surface; editor, preview, and Vim `gd` preserve the original URL. +- **Editor settings tabs.** One line, equal spacing, balanced edge padding, horizontal scrolling at narrow widths, and selected-tab visibility. Resizing and maximized layouts were checked across 820 to 2560px widths. +- **Quick Capture over native macOS fullscreen apps.** Panel behavior allows the small editor to appear over fullscreen Spaces; pin, save, and dismiss were exercised. +- **Sidebar drag preview.** Dragging a partially clipped row uses a full-row preview instead of a cropped screenshot. +- **Daily rollover.** Real rolled-over tasks replace template placeholder tasks; preserves existing/nested tasks and other sections instead of leaving three empty checkboxes. +- **Literal bracket text.** `[EE]` remains text unless a valid Markdown reference definition exists, across editor hosts. + +An earlier report also mentioned Kanban Today appearing on tomorrow's calendar date. The final v2.50.3 release notes do not identify a separate calendar-date fix. Do not invent a shipped fix for that report. + +Issues #754 and #764 were commented on and closed. Nix PR #782 in ZenNotes/zennotes is closed, not merged; packaging hashes were committed separately. Do not infer an additional nixpkgs PR from that fact. + +### Media and docs + +[Release pack](/Users/adibhanna/Developer/opensource/zennotes/docs/releases/v2.50.3/RELEASE_NOTES.md) + +Four videos are stored in the release pack's `media/` directory and the website's `public/release-media/v2.50.3/`: + +- `window-title-bar-demo.mp4` +- `application-links-demo.mp4` +- `daily-editor-fixes-demo.mp4` +- `sidebar-drag-fix-demo.mp4` + +They were attached to the GitHub release and website release page. The sidebar demo uses app captures with an illustrated pointer; the other three use native macOS footage. Tweet copy is in the release pack's `twitter-post.md`. + +At that milestone: typechecks, desktop build/packaged CLI, app-core 2,087 tests, shared-domain 1,627, and desktop 767 passed with existing skips. The signed/notarized DMG passed Gatekeeper and stapled-ticket checks. Native Windows and Hyprland UI were not exercised. + +## 4. Ecosystem boundary plan and completed local implementation + +The user asked whether the web version could be added to Cloud so users could sign in and see their notes online. We assessed the architecture and prioritized separating responsibilities before adding that product capability. + +### Agreed repository responsibilities + +| Repository | Responsibility | +| --- | --- | +| `ZenNotes/zennotes` | Desktop and web shells, shared TypeScript editor/domain/contracts, read-only share viewer | +| `ZenNotes/znserver` | Independently built Go self-hosted server, consuming pinned web artifacts | +| `ZenNotes/website` (private) | Laravel marketing, accounts, billing, Cloud, publishing, consuming viewer artifacts | +| `ZenNotes/zennotesios` | iOS shell and native files/iCloud/keychain behavior | +| `ZenNotes/zennotesandroid` | Android shell and Storage Access Framework behavior | +| `ZenNotes/tui` | Go TUI, CLI, and MCP, with local/self-hosted backends | + +Keep desktop/web/editor together. Avoid a framework rewrite, new microservices, merging Go and Laravel backends, moving private Laravel code public, or changing Capacitor versions as part of this split. + +**Cloud browser login/private-note editing has not been implemented.** It remains a separate product stream requiring decisions about sessions/authentication, cache/service-worker isolation, revisions/conflicts, encrypted vaults, and recovery. Cloud integration in the TUI is also outside this boundary work. + +### Contract and shared-core boundaries + +- `bridge-contract` owns portable passive types and `ZenPlatform`; no NodeJS leakage or reverse dependency on domain implementation. +- `shared-domain` depends on contracts. Pure task/path/rename/demo logic moved into it; compatibility reexports retain existing callers. +- App-core exposes public host APIs for navigation, notes and batches, folder/database/task actions, settings, commands, dialogs, editor capabilities/attachments/geometry, and immutable shell/workspace observations. +- Hosts no longer reach into raw store or CodeMirror references through the public contract. +- Mobile private imports and copied source-tree integrations were removed. +- Native operations coordinate pending saves, stale host context invalidation, workspace reservations, relocation/save/move/reopen/rollback, and failed-rollback recovery. +- Shared package archives enforce singleton React/CodeMirror/Lezer dependencies and include assets, WASM, fonts, and consumer build support. + +### Mobile consumption + +- Android and iOS consume exact vendored package archives with lockfiles/manifests. They no longer require a sibling desktop source checkout. +- Removed `.zennotes-commit` and the old preparation/upstream-copying mechanism. +- Android SAF distinguishes missing files, missing directories, revoked access, and provider failures; a native provider fixture exercises those distinctions. +- iOS initializes native preferences and typing behavior before opening the editor; deployment minimums align at iOS 15. +- Actual simulators/emulators and runtime fixtures were used, not only static checks. + +### Server and cross-language contracts + +- Exact-byte task/date fixtures have shared SHA provenance in TypeScript, Go server, and TUI, including near-midnight Los Angeles and Auckland cases. +- Go HTTP contracts exercise authentication, root/prefixed deployments, reads/writes, invalid paths, and current/legacy routes. +- Go tests/builds run without Node or frontend dependencies. +- Web artifact importer validates protocol, source metadata, archive/file SHA-256, archive path/type safety, and immutable outputs. +- Server extraction templates use module `github.com/ZenNotes/znserver` and include Docker, Nix, release, and CI preparation. +- A disposable git-filter-repo 2.47 rehearsal covered 979 commits and 160 server paths. Original refs were unchanged; actual remote history import has not occurred. +- Go-only Docker arm64/amd64 root and prefix tests exercised auth/assets/exact writes/logout. A released-v2.50.4 to candidate to v2.50.4 rollback preserved exact note bytes. +- Go-only Nix aarch64-linux build/runtime proof was completed. + +### Public share viewer and Laravel + +- Rebuilt the maintained viewer from reproducible source with a read-only surface, safe Markdown fallback, themes, attachments, and lazy diagrams. Unsafe interactive plots were excluded. +- Historical viewer commit `c534a1d0` reproduced 67 of 73 historical files; no exact-provenance claim was made for the other six. +- Laravel importer verifies artifacts and installs both current and retained manifests on fresh deployments so old pages can still fetch lazy assets. The old root viewer remains a fallback. +- Protected/manual draft artifact workflows were prepared, but clean production artifact publication and cutover remain pending. + +### Main reference documents + +- [Architecture and repository plan](/Users/adibhanna/Developer/opensource/zennotes/docs/specs/ecosystem-boundaries-and-repository-plan.md) +- [Release and cutover gates](/Users/adibhanna/Developer/opensource/zennotes/docs/boundary-release-cutover.md) +- [Server extraction rehearsal](/Users/adibhanna/Developer/opensource/zennotes/docs/server-extraction-rehearsal.md) +- [v2.50.4 integration](/Users/adibhanna/Developer/opensource/zennotes/docs/v2.50.4-boundary-integration.md) +- [Monorepo architecture](/Users/adibhanna/Developer/opensource/zennotes/docs/monorepo-architecture.md) +- [Web architecture](/Users/adibhanna/Developer/opensource/zennotes/docs/web-architecture.md) +- [Boundary validation evidence](/Users/adibhanna/Developer/opensource/zennotes/dist/ecosystem-boundary-validation/VALIDATION.md) + +Older validation documents saying nothing was committed/pushed describe their local checkpoint. They predate the five approved PR snapshots. Later fixes remain local as stated in this handoff. + +## 5. Incorporating the user's v2.50.4 release + +The user fixed and released v2.50.4 in a separate worktree while boundary work continued. + +- Released commit: `850cf5f8a7f7e10d3f47df1c5732209d8e0c2dcc`. +- Annotated tag object: `c82ec31a9be663d8fa3827c865c28f8bb5020bd7`. +- [Release build](https://github.com/ZenNotes/zennotes/actions/runs/35036589163) passed and published 29 assets. +- [PR #787](https://github.com/ZenNotes/zennotes/pull/787) merged September 15 at 23:38 UTC. + +Integrated fixes include #783 wikilink picker in code, #784 live modified-date tokens, #785 asset rename/move reference rewriting, and #786 forwarded/canceled Kanban tasks. + +Thirty-eight changed paths were reconciled by three-way comparison into the original dirty boundary tree without changing its HEAD or index. The one lockfile conflict retained the version changes and new TypeScript dependency. Asset actions were integrated with workspace save/reservation guarantees. + +The desktop PR snapshot subsequently used main `9a2e5e1a87f5e095692f0084a66ca012cfafe011`, including packaging metadata. The user's separate release worktree at `.claude/worktrees/pensive-mendeleev-d841e7` should be left alone. + +## 6. Desktop Node CLI to Go CLI/TUI migration, local only + +The user wanted existing desktop-installed Node CLI users to move to the new Go tool with minimal friction, retaining the `zn` command and desktop installation flow. + +[Migration spec](/Users/adibhanna/Developer/opensource/zennotes/docs/specs/desktop-cli-tui-migration.md) + +### Implemented behavior + +- Desktop consumes a pinned Go binary rather than importing Go source. +- Durable versioned binaries live under `/cli/terminal/versions`, with an atomic current pointer and stable `/cli/zn` launcher. +- Existing desktop-owned links upgrade on startup. Old `resources/zen` launchers forward appropriately. +- Homebrew/manual installations are protected from replacement. +- Bare `zn` retains help behavior. `zn tui` opens the interactive application. +- Commands default to the desktop application's workspace; the TUI defaults to its terminal workspace. Explicit `--workspace-source app|terminal`, environment settings, and vault/server overrides take precedence. +- Desktop profile/token precedence is preserved; terminal credentials do not silently override desktop credentials. +- MCP pins the first successfully resolved backend for its process lifetime. An initial failed resolution may be retried. +- Node remains available for the first transition release. `ZENNOTES_CLI_ENGINE=legacy` explicitly selects rollback before invoking a command. A failed Go command is never automatically rerun through Node, avoiding duplicate writes. +- Direct `cli.js` and `mcp.js` remain legacy Node entry points. +- Settings reports installed version and repair errors; launcher resolution handles canonical path aliases. + +### Linux creation timestamps and stale AppImage links + +- Go uses Linux statx birth time and `.zennotes/note-metadata/.metadata.json` to preserve creation dates across atomic saves. +- Desktop/Node/MCP read the same format. Renames, moves, trash/restore, and deletion carry the sidecars correctly. +- Malformed metadata blocks the Markdown write rather than destroying creation-date information. +- macOS preserves native filesystem creation time. +- Explicit root/inbox layouts and custom system-folder configuration are honored. +- Laravel Cloud's path allowlist accepts the exact creation-metadata suffix. This must deploy before desktop starts syncing these files. +- Installer receipts allow automatic repair only for the exact recorded path and target. +- An unknown stale link requires review in Settings, an expiring main-process token, a link-unchanged check, and backup. Foreign PATH entries, changed-link races, and non-writable/root-owned links are protected. + +### Important source areas + +Desktop main process: `apps/desktop/src/main/cli-install.ts`, `terminal-runtime.ts`, `note-creation-metadata.ts`, and accompanying tests. Launcher/packaging: `apps/desktop/build/zen`, `after-pack.js`. Artifact/compatibility tooling: `tooling/scripts/terminal-artifact.mjs`, `verify-terminal-compat.mjs`, `terminal-launcher.test.mjs`. + +Go: backend target selection, CLI/TUI workspace handling, vault metadata, atomic writes, and platform file-time implementations in the TUI repository. Laravel: `app/Services/VaultPathService.php` and sync API metadata coverage. + +### Release state and exact pending candidate + +[Production manifest](/Users/adibhanna/Developer/opensource/zennotes/apps/desktop/terminal-release.json) remains: + +```json +{"schemaVersion": 1, "release": null} +``` + +Normal packaging with a null release removes staged local runtimes and retains the Node fallback. Local rehearsal hashes are not production pins. + +Prepared release worktree: `/tmp/zn-tui-v0.2.0-release` (`/private/tmp/...` canonical path), branch `v0.2.0`, HEAD `3ccdc81547780eeee325eb1e4b8b09dc85d17f28`. + +It contains **28 staged files, with no commit**. Scope file: `/tmp/zn-go-release-scope.json`. Staged binary-diff SHA-256 recorded for that frozen candidate: `f533f738e91a9a645cd765a7b3295205f7c8022baf11f0f10ac6fd94814e7644`. + +The later TUI theme/quit changes are NOT included in this frozen release worktree. Refresh its scope/patch and rerun affected verification before seeking final approval. Current published TUI is still [v0.1.0](https://github.com/ZenNotes/tui/releases/tag/v0.1.0). + +Release pack: + +- [Publish plan](/Users/adibhanna/Developer/opensource/zennotes/docs/releases/cli-v0.2.0/PUBLISH_PLAN.md) +- [Verification](/Users/adibhanna/Developer/opensource/zennotes/docs/releases/cli-v0.2.0/VERIFICATION.md) +- [Release notes](/Users/adibhanna/Developer/opensource/zennotes/docs/releases/cli-v0.2.0/RELEASE_NOTES.md) + +The same directory contains `GO_RELEASE_SCOPE.json`, `go-release.patch`, `COMMIT_MESSAGE.txt`, and launch copy. + +### Publish/pin sequence after explicit approval + +1. Refresh the candidate with later TUI fixes, verify scope/source parity, and recheck remote main and whether v0.2.0 exists. Reconcile any remote movement before publishing. +2. Commit/push the approved scope and tag the exact source commit using the documented release process. +3. Confirm the release workflow publishes all six archives plus checksums from that tag. +4. Download the actual release assets, verify checksums, commit provenance, version 0.2.0, and protocol 1. +5. Pin actual published hashes/source commit for desktop `darwin-arm64`, `darwin-x64`, `linux-arm64`, and `linux-x64`. Go's archive architecture `amd64` maps to manifest `x64`. +6. Run importer, native integration, and compatibility checks using downloaded releases with local overrides removed. +7. Deploy Laravel's metadata allowlist before the signed/notarized desktop release. Retain Node rollback in this first migration release. + +Standalone macOS signing/notarization remains separate from Developer ID signing of a Go binary embedded inside the desktop app. + +## 7. Desktop and mobile bug fixes #790, #791, #792 + +All are implemented locally and tested. They are not in a published release. The user's latest authorization to comment and close them was executed. + +[Combined evidence](/Users/adibhanna/Developer/opensource/zennotes/dist/issues-790-792/README.md) + +### #792: rectangular Vim block editing + +Cause: CodeMirror multi-selection was disabled, collapsing Vim's per-row ranges. The shared Vim extension now enables multiple selections in all editor hosts. + +Coverage includes insert, append, reverse selection, delete, change, yank/paste, and short rows. Native Electron reproduced the original first-row-only insertion; the fix inserted and saved the prefix on all three rows. Ten new regressions and 57 focused tests passed. + +Key files: `packages/app-core/src/lib/cm-vim-visual-highlight.ts` and `cm-vim-visual-block.test.ts`. + +Final Developer ID signed macOS rehearsal: `/tmp/zn-issues-pack-signed/mac-arm64/ZenNotes.app`. Its runtime evidence is `dist/issues-790-792/792/packaged-runtime.json`. The directory-only package lacks `app-update.yml`, so the expected background update-feed error is a fixture limitation, not a claimed release regression. + +The later Discord rectangular-edit report duplicates this issue. User Downloads attachment paths are no longer present, but the original issue videos are retained as `792/reported-desktop.mov` and `792/reported-tui.mov`, with extracted frames. + +### #791: deleted Cloud vault still displayed as linked/up to date + +A structured vault-level NOT_FOUND response retires the exact local association and active sync state. A missing item/revision first requires a separate vault-manifest check. Network, authorization, and server errors do not unlink the vault. A concurrent replacement link is protected. + +The complete old sync state, including unsent merge drafts, is archived under uniquely named inactive `retired-states`. Local notes and other vaults remain intact. Settings removes the deleted destination and stale actions/up-to-date message, keeps other destinations usable, and displays a useful error without Electron's internal prefix. + +Desktop and shared mobile hosts use the same rules. Native desktop manual sync and automatic five-second polling passed with Settings open throughout, against authenticated local Cloud fixtures. Each mobile adapter passed 20 integration tests against its installed packages. Physical-device Cloud deletion was not tested. + +Key areas: shared-domain `cloud-vault-availability.ts`, `cloud-sync-host-service.ts`; desktop Cloud sync service/filesystem; core CloudSettings/auto-sync; mobile adapters. + +### #790: pacman updater relaunch button appears to do nothing + +Installation now has visible state, allows one privileged process at a time, disables pkexec's hidden terminal fallback, distinguishes cancellation code 126 from authorization failure 127, and offers recovery through Details > About. Startup update checks cannot overwrite a pending result. + +Actual Linux arm64 Electron and PacmanUpdater verified feed/checksum handling, missing agent, cancellation, pending authorization, package failure, successful quit/relaunch, and error persistence past the eight-second startup timer. The feed and privileged subprocess were fixtures. No actual privilege elevation or system upgrade was performed. + +The built pacman archive owns a lowercase `/usr/bin/zennotes` launcher, provides `zennotes`, and conflicts with `zennotes-bin`; AUR metadata has the reciprocal `ZenNotes` conflict. An isolated Arch pacman root verified ownership, conflicting-package rejection, and alias removal on uninstall. Dependency checks and maintainer scripts were disabled in that fixture. + +Native Niri/Hyprland Wayland and graphical polkit behavior still require a real Linux host. + +### GitHub disposition + +- [#790 resolution comment](https://github.com/ZenNotes/zennotes/issues/790#issuecomment-5699697681) +- [#791 resolution comment](https://github.com/ZenNotes/zennotes/issues/791#issuecomment-5699698313) +- [#792 resolution comment](https://github.com/ZenNotes/zennotes/issues/792#issuecomment-5699699110) + +All three were verified CLOSED with reason COMPLETED. Comments explicitly state the changes are local and not yet committed/released, and describe validation limits. Do not duplicate comments or reopen issues without a reason/user request. + +[Closure record](/Users/adibhanna/Developer/opensource/zennotes/dist/issues-790-792/github-resolution.json) + +### Latest mobile candidate, newer than boundary PR archives + +Current app-core candidate: `2.50.4-core.h55a7458f56e50033`. + +Contract/domain candidate: `2.50.4-boundaries.haeb944b71e3a163a`. + +Core archive SHA-256: `94f00a496b2d6bbc98b90b97afd70cf106fe239319dfeb91bf2cf1a46d764083`. + +[Candidate manifest](/Users/adibhanna/Developer/opensource/zennotes/dist/issues-790-792/checks/mobile-core-candidate.json) + +Archive: `/Users/adibhanna/Developer/opensource/zennotes/dist/shared-packages/zennotes-app-core-2.50.4-core.h55a7458f56e50033.tgz`. + +These honestly record dirty source based on original HEAD a8fc4fc9. Do not replace them with older h8a... boundary candidates, and do not publish them as clean production artifacts. Earlier web `2.50.4-web.hffc8c055ae2667f2` and viewer `2.50.4-viewer.h475ea70770234498` artifacts also require clean publication/repinning before production. + +## 8. TUI usability and website follow-up, local only + +The user supplied feedback about unreadable light mode, missing Homebrew instructions, competing `zn` commands, tag completion, themes, video viewing, quitting, and desktop rectangular editing. + +The concrete usability gaps were fixed. Tag completion, custom whole-interface themes, and standalone macOS signing remain separate unfinished Go CLI/TUI work. They are not desktop editor settings work. + +### Completed + +- TUI renders explicit foreground/background across every row, including blanks and nested color reset sequences. Light-mode text no longer inherits a dark terminal background. +- Preserves nested selected/link colors and honors no-color output. Improved muted contrast: light FgDim `#665c54`, light FgMuted `#76695f`, dark FgMuted `#9d8e7d`. +- Footer exposes `:qa quit` early. Help and README distinguish closing one note with `:q` from saving/quitting the application with `:qa`; command palette carries the hint. +- Did not assign Space q q because Space q already opens Quick Capture. +- Website leads with `brew install zennotes/tap/zn`, retaining Go installation and all six binary downloads. +- Explains competing commands using `type -a zn` and `"$(brew --prefix)/bin/zn" tui`, without claiming the pending migration has shipped. +- Links official Apple verification guidance. Does not disable Gatekeeper or claim unsigned standalone binaries are signed. +- Copied the existing silent TUI tour and poster byte-for-byte into website `public/release-media/tui-v0.1.0/`. +- Added a native, user-started HTML video player under an expandable recording section, with controls, playsinline, and preload none. Kept the interactive preview and avoided autoplay. +- README now points to `https://zennotes.org/tui#recording`. Publish the website player before publishing that README link. + +### Files and verification + +TUI files: `README.md`, `internal/tui/app.go`, `commands.go`, `helpdata.go`, `hints.go`, `textutil.go`, `theme.go`, new `theme_render_test.go`. + +Laravel files: `resources/views/tui.blade.php`, `resources/css/tui.css`, `tests/Feature/TuiPageTest.php`, and new `public/release-media/tui-v0.1.0/` media. + +Go full tests/vet passed; final theme tests passed after contrast adjustments. Rendering regressions fail on original code and pass after the fix. Actual tmux TUI runs checked light/dark switching, closing one of two tabs with `:q`, and saving/exiting with `:qa`. Screenshots are rendered from real ANSI captures, not mockups. The fixture explicitly unsets the agent environment's `NO_COLOR=1` for color checks. + +Laravel: five Pest tests, 41 assertions, Vite production build, and Pint passed. Actual Herd website was inspected at desktop and 390px widths without horizontal overflow. Native video playback reached 41 seconds without media error. One in-app browser tab crashed during accessibility automation of the native pause button; a fresh tab passed keyboard play/pause at 19 seconds. This is not universal-browser proof. + +[Evidence and preservation record](/Users/adibhanna/Developer/opensource/zennotes/dist/tui-feedback/README.md) + +The same directory contains runtime-check.py, runtime.json, light-before.png, light-after.png, dark-after.png, ANSI/cast/gif captures, and git-preservation.json. Temporary binaries are `/tmp/zn-tui-feedback-before` and `/tmp/zn-tui-feedback-after`; fixture root is `/tmp/zn-tui-feedback-runtime`. + +### Still outstanding + +- [TUI #3: tag-value completion](https://github.com/ZenNotes/tui/issues/3), open. +- [TUI #1: desktop-like colorschemes](https://github.com/ZenNotes/tui/issues/1), open. The contrast fix does not implement customizable whole-interface themes. +- Standalone macOS release signing/notarization. No separate GitHub issue was identified during this handoff. + +The prepared 28-file Go release candidate has not been refreshed with these changes. + +## 9. Verification summary and limits + +These are results at recorded local checkpoints. They were not all rerun for this documentation-only handoff, and they do not supersede current remote CI failures. + +### Boundary checkpoint + +- 4,806 source tests passed, with five existing skips; eight typecheck tasks. +- Fifty browser checks each against nested Vite 6 and Vite 8 consumers. +- Android: 138 tests, native build/lint/unit checks, four instrumentation cases, 20 runtime checks, three cold starts. +- iOS: 102 tests, native simulator build, 20 runtime checks, three cold starts. +- Laravel: earlier full 793 tests / 6,272 assertions; after integration, 17 focused tests / 141 assertions, 11 importer checks, six real Laravel browser checks. +- Go server/TUI tests, vet, builds, protocol/fixture tests; five artifact-packer tests; production audit with zero advisories. +- Real app/runtime exercises covered Unicode and trailing spaces, Find, tasks, attachments, renames, trash/restore, comments, native vault moves/reopen, stale contexts, and cold starts. + +### CLI transition checkpoint + +- Sixty command-compatibility cases on macOS and 60 on Linux, including exact piped bytes. +- Actual MCP process parity and backend pinning. +- Actual Raycast isolated-vault list/archive/restore checks; fixture extension uninstalled afterward. +- Thirty concurrent app/CLI saves and 643 reads showed complete files and stable creation dates. Content remains last-writer-wins; this does not provide conflict merging. +- Native signed macOS Electron and embedded Go strict-signature checks. +- Actual Linux arm64 AppImage UI, and durable CLI behavior after removing the AppImage extraction directory. +- Go tests on macOS arm64, Linux arm64, and emulated Linux x64; six release archives built; Rosetta/x64 emulated CLI checks passed. + +### Recent issue checkpoint + +- Shared-domain: 1,666 passed. +- Desktop: 848 passed, four existing skips. +- App-core: 2,368 passed, one existing skip. +- Android: 149 passed. iOS: 113 passed. +- Later focused regressions: updater 35; Cloud settings/status 53. +- Eight root typechecks plus both mobile typechecks, boundary checks, and production web builds passed. + +### Remaining validation limits + +- Native Linux x64 desktop GUI is unproven; emulation did not reach the renderer. CLI emulation is not a substitute. +- Native Hyprland/Niri Wayland, graphical polkit, and FUSE behavior remain unproven. +- Native Windows TUI execution remains outstanding, and its current PR CI fails. Desktop CLI installation on Windows is disabled. +- Real account-backed Cloud/iCloud entitlement, account-switch, and revocation tests need isolated staging accounts/devices. Recent mobile Cloud-vault deletion was package/adapter-tested, not tested on physical devices with live Cloud. +- New packaged notarization still depends on release CI credentials; local Developer ID signing does not prove a newly notarized distribution. +- macOS Nix runtime and a live open-browser-tab viewer asset rollout remain cutover checks. + +## 10. Recommended next actions + +1. **Preserve and understand current state.** Read this file, repository-state.json, and the PR worktree manifest/backups. Recheck live remote state before touching branches. Keep the unrelated iOS version bump and all pre-existing indexes intact. +2. **Investigate the five draft PRs' CI failures.** Read logs and separate real regressions, platform failures, and clean-artifact prerequisites. Do not bypass artifact verification to get green checks. +3. **Prepare updated review scopes locally.** Reconcile the newer CLI, #790 to #792, TUI, and website changes into the appropriate snapshots. Refresh the isolated Go release candidate with later TUI fixes. Review exact diffs and run relevant checks before requesting explicit commit/push approval. +4. **Finish meaningful platform/staging tests.** Prioritize native Linux x64 and Wayland/polkit/FUSE, Windows TUI, and account-backed mobile/Cloud scenarios. Report unavailable environments honestly rather than substituting compilation claims. +5. **Publish and pin the compatible Go CLI once approved.** Use real release downloads and checksums, not rehearsal artifacts. Treat standalone macOS signing/notarization as a separate outstanding release concern. +6. **Respect deployment dependencies.** Laravel metadata allowlist before the desktop CLI migration; website player before the README's new recording link. +7. **Publish clean boundary artifacts and repin consumers.** Core/contracts/domain/web/viewer archives need clean approved source provenance, publication, and mobile/Laravel/server repinning. Dirty review candidates are not release assets. +8. **Complete znserver extraction/cutover.** Work in a disposable clone after an approved checkpoint; establish the empty remote's history/base, protect publication workflows, run Go/Docker/Nix/release checks, preserve distribution identity, and verify rollback. Disable the old Docker publisher before enabling the replacement. Remove `apps/server` only after the independent release and rollback are proven. +9. **Continue distinct feature work if requested.** TUI tag completion, customizable interface themes, and standalone signing remain open. Browser access to private Cloud notes remains a future product implementation, not an already completed part of this refactor. + +## 11. Practical commands and evidence locations + +Check scripts and flags before running artifact producers; output directories are immutable by design. + +Desktop root commands used during verification: + +```sh +npm run typecheck +npm run test:run --workspace @zennotes/shared-domain +npm run test:run --workspace @zennotes/app-core +npm run test:run --workspace @zennotes/desktop +npm run test:terminal +npm run test:app-core-package +npm run test:app-core-browser +npm run check:contract-fixtures +npm run test:web-artifact +``` + +Artifact entry points include `npm run artifact:app-core`, `npm run artifact:web`, and `npm run pack:share-viewer`. Consult their manifests and documented clean/local flags before producing anything. + +TUI commands: + +```sh +go test ./... +go vet ./... +go build -o /tmp/zn-handoff-check ./cmd/zn +``` + +Desktop compatibility runner: `node tooling/scripts/verify-terminal-compat.mjs /absolute/path/to/zn` from the desktop root. + +Local-only artifact staging uses `terminal-artifact.mjs --local-binary ... --license ...`. Local desktop packaging requires an explicit terminal artifact directory and `ZENNOTES_ALLOW_LOCAL_TERMINAL=1`; CI rejects these local candidate overrides. Never carry them into production pin verification. + +Laravel runs through Herd at `http://zennotes.test`; do not start artisan serve. Relevant focused check: `php artisan test --compact tests/Feature/TuiPageTest.php`, followed by appropriate project build/lint checks when changing those files. + +Main evidence roots: + +- `/Users/adibhanna/Developer/opensource/zennotes/dist/ecosystem-boundary-validation/` +- `/Users/adibhanna/Developer/opensource/zennotes/dist/ecosystem-boundary-validation/v2.50.4/` +- `/Users/adibhanna/Developer/opensource/zennotes/dist/cli-tui-migration-assessment/` +- `/Users/adibhanna/Developer/opensource/zennotes/dist/cli-tui-transition/` +- `/Users/adibhanna/Developer/opensource/zennotes/dist/issues-790-792/` +- `/Users/adibhanna/Developer/opensource/zennotes/dist/tui-feedback/` +- `/Users/adibhanna/Developer/opensource/zennotes/dist/handoff-2026-09-16/` + +Preservation snapshots also exist at `/tmp/zn-cli-migration-state.json`, `/tmp/zn-cli-release-state.json`, `/tmp/zn-issues-790-792-state.json`, `/tmp/zn-792-state.json`, `/tmp/zn-issues-packaged-state.json`, and `/tmp/zn-tui-feedback-baseline.json`. Prefer durable worktree backups when available because temporary paths can disappear. + +Many release packs and `dist/` artifacts are ignored local files. GitHub alone cannot reconstruct all evidence or the latest unpublished changes. A new agent should work on this machine/workspace or explicitly transfer these directories along with the source changes. + +### Suggested opening instruction for the next agent + +> Read `/Users/adibhanna/Developer/opensource/zennotes/docs/agent-handoff-2026-09-16.md` and the linked state snapshots first. Continue the ZenNotes boundary refactor, desktop-to-Go CLI migration, and local bug-fix release preparation. Preserve every original working tree and index. The five draft PRs contain an older snapshot and have failed CI; later fixes are local. Investigate those failures and prepare updated, tested review scopes. Do not commit or push without asking for and receiving explicit approval. Do not treat closed issues or passing historical local tests as evidence that the fixes have shipped or CI is green. + +## 12. Update, later on September 16: CI failures diagnosed, review scopes prepared + +A second pass on the same day investigated the five draft PRs' CI failures, +fixed every locally fixable cause, verified the fixes, refreshed the Go CLI +v0.2.0 candidate with the later TUI fixes, and prepared grouped review scopes. +With the maintainer's approval the four CI-fix commits were then pushed to the +PR branches (zennotes `1150ae3b` and four follow-ups through `2f2f00dc` that +CI runs revealed, zennotesandroid `cdab60d1`, zennotesios `09ff9790`, tui +`ee7aa647`); nothing else was committed, pushed, tagged, commented, or +deployed. Every original tree's staged index is byte-identical +to the state recorded in section 2. + +Read [dist/handoff-2026-09-16/review-scopes/README.md](/Users/adibhanna/Developer/opensource/zennotes/dist/handoff-2026-09-16/review-scopes/README.md) +next. It holds the root causes, the per-PR fix patches (verified to apply to +each PR snapshot), the desktop later work split into six reviewable groups +with an assembly proof, the refreshed two-commit Go release candidate, the +verification log, and the exact approval asks. Patches live beside it under +`patches/`, evidence under `evidence/`. + +Runtime validation of the current tree followed: desktop dev and packaged +builds over CDP, the self-hosted web shell over CDP, the TUI in tmux, and the +iOS simulator, and the Android emulator (SDK under Homebrew's command-line +tools, OpenJDK 21) all pass core flows with isolated data. See section 1c of the review-scopes README. + +With approval, the Go server was then extracted to `ZenNotes/znserver` +(`main` at `975412e8`, 175 commits, pinned to the published clean web artifact +`web-2.50.4-web.h876d73fd3124e1fe`), with branch protection and the two +release environments configured; the Docker channel was swapped (main publisher disabled, `adibhanna/zennotes:2.50.5` +pushed from znserver; `latest` untouched). The monorepo cutover followed as +draft PR #795 (`refactor/server-cutover`, commits `e20bd43b` and `8caedda8` on top +of the boundary PR): `apps/server`, the old Docker publisher, and the server Nix +package are gone, and every script that still built the server now resolves +the pinned znserver release through `tooling/scripts/server-binary.mjs`. See +the review-scopes README, section 3b. + +Short version of the causes: a cold npm cache has no registry metadata for +`--offline` range resolution; `tsc` rejects backslash include globs on +Windows; CRLF checkouts break byte-hashed contract fixtures on Windows; the +mobile lockfiles lacked the desktop's audit `overrides`; the website pin is a +dirty viewer candidate by design and needs a clean published artifact; CodeQL +flagged eight items in the large diff, all addressed. diff --git a/docs/boundary-release-cutover.md b/docs/boundary-release-cutover.md new file mode 100644 index 00000000..87b8977f --- /dev/null +++ b/docs/boundary-release-cutover.md @@ -0,0 +1,91 @@ +# Boundary release and repository cutover + +Local preparation is verified. No workflow here has been dispatched, and no +release owner or production build setting has changed. + +## Reviewable local changes + +| Repository | Review groups | +| --- | --- | +| Main `zennotes` | Contract ownership/fixtures; public core API and mutation lifecycle; package producer/consumer checks; web/Go artifact boundary; maintained public viewer; extraction/distribution templates; architecture evidence | +| Android | Vendored package pins and public imports; native workspace rollback and SAF error contracts; boundary/runtime/provider fixtures | +| iOS | Same package/public API migration and native lifecycle checks; existing unrelated Xcode project changes must remain separate | +| TUI | Exact-byte task and HTTP contract fixtures, opt-in real-server verification, documentation | +| Laravel | Verified viewer importer and retained pins; current payload integration; document/fallback behavior; read-only viewer browser gate | + +The user's pre-existing staged changes in main are preserved. Do not stage the +entire ecosystem as one change. Review the existing index first, then make focused +checkpoints per group after explicit approval. The core package and its consumers +must move together; they cannot mix the package store with private source imports. + +## Publication order + +1. The released v2.50.4 source has been reconciled locally and affected checks + pass; see [the integration record](v2.50.4-boundary-integration.md). Review and + approve source commits and branch-history alignment before any push. The + existing HEAD and index are unchanged. +2. Configure the `boundary-artifacts` GitHub environment with required review. + `.github/workflows/boundary-artifact-release.yml` accepts an approved full source + SHA, validates/builds the selected artifact, and creates a draft release only. + It does not publish to npm. The machine has no npm identity; scope ownership + must be settled before choosing registry publication instead of archives. +3. Verify the draft's consumer behavior, then approve publication. Web and viewer + manifests already name their immutable final release URL. Dirty manifests have + no URL and ordinary consumers reject them. Never hand-edit dirty provenance to + make a candidate appear released. +4. Update both mobile consumers from the published package set. Retain the exact + archives in their repository `vendor` directories and update lockfiles and + checksum manifests together; fresh CI must need no main-repository checkout. + Run native/account-backed staging gates before native releases. +5. Update Laravel's viewer pin, placing the prior released pin in + `resources/share-viewer/retained/`. `viewer:install` rebuilds that complete + supported asset set on every deployment. Run Pest and the actual-payload Chrome + harness. Add `npm run viewer:install` to the production build only in the reviewed + deployment change. The retained legacy root bundle permits rollback during the + migration. Do not assume Laravel Cloud preserves an old build directory. +6. Pin a clean published web manifest in the extracted Go source. Its API-only + tests, embedded build, Docker build, and Nix build must pass without Node. + +## Go repository and channel order + +Status: done on September 16, 2026 through the manual publisher and a channel +rehearsal; see the server extraction document. The steps below are kept as the +record of the order that was followed. + +- Verify `ZenNotes/znserver` is still empty before import. Preserve old repository + history and tags. The documented dry-run is complete; actual filtering must run + only in a disposable clone of the approved checkpoint. +- Copy `tooling/server-repository` into the extracted tree, rewrite module imports, + and retain fixture provenance. Add the reviewed web pin and server release + metadata. The source-copy rehearsal automates these transformations. +- Configure required CI checks, review/branch protections, security reporting, + protected release environments, and minimum necessary publisher credentials. + Do not copy website/account credentials into a public repository. +- Run fresh destination CI. The manual `release.yml` creates draft binaries and + SHA-256 sums for Linux/macOS amd64/arm64 and Windows amd64. Complete candidate + installation and rollback checks before publishing that draft. +- Disable the main repository's Docker publisher before enabling the destination's + manual publisher. Preserve `adibhanna/zennotes`, amd64/arm64, tags, non-root UID, + port, volume paths, config variables, and binary name. Configure the protected + `server-docker-publisher` environment. Move Nix server source/artifact pins in a + separate reviewed channel update; desktop Nix/AUR/Homebrew stay in main. +- After one verified destination release and channel rollback rehearsal, remove + `apps/server`, its npm workspace and old publisher. Keep `dev:web-stack` using the + configured external checkout/binary. Until then, the old source stays available. + +## Acceptance that needs an external environment + +- Real Cloud/iCloud entitlements, account switching/revocation, and sync with + isolated staging accounts; local native/provider tests do not claim this proof. +- Clean remote CI and macOS Nix; local Nix proof is aarch64 Linux. +- Open-tab behavior through the chosen server rollout: unlike Laravel's retained + manifest set, a single embedded Go binary contains one browser bundle. Preserve + old hashed assets at the deployment layer during overlap, or define and test a + recoverable reload path before promising seamless tab survival. +- Installed-client support policy. Public release inventory is recorded, but App + Store/TestFlight availability and older installed versions need owner input. + Keep existing HTTP aliases and compatibility exports in the meantime. + +Cloud browser login/editing is a separate feature with its own auth, cache, +revision/conflict, encrypted-vault, and draft-recovery gates. This migration does +not expose private notes through browser login. diff --git a/docs/explanation/how-zennotes-works.md b/docs/explanation/how-zennotes-works.md index 8e301b62..e2a1d1e6 100644 --- a/docs/explanation/how-zennotes-works.md +++ b/docs/explanation/how-zennotes-works.md @@ -51,7 +51,7 @@ The current architecture is stricter: - `packages/app-core` is the shared product - `apps/desktop` is the Electron shell - `apps/web` is the browser shell -- `apps/server` is the server runtime for browser and remote use +- the Go server in ZenNotes/znserver is the server runtime for browser and remote use This is the real difference between: diff --git a/docs/explanation/team-collaboration-plan.md b/docs/explanation/team-collaboration-plan.md index 10a667a9..f78a66d4 100644 --- a/docs/explanation/team-collaboration-plan.md +++ b/docs/explanation/team-collaboration-plan.md @@ -2,7 +2,7 @@ > **Status:** Proposed — not yet implemented. > **Last updated:** 2026-05-26 -> **Scope:** Turn the self-hosted Go server (`apps/server`) from a single-secret, +> **Scope:** Turn the self-hosted Go server (now in ZenNotes/znserver) from a single-secret, > single-vault deployment into a multi-user, team-aware server that companies can > run themselves, while keeping notes as ordinary `.md` files on their own disk. diff --git a/docs/monorepo-architecture.md b/docs/monorepo-architecture.md index 8031ac76..f87dbb2f 100644 --- a/docs/monorepo-architecture.md +++ b/docs/monorepo-architecture.md @@ -1,6 +1,9 @@ # ZenNotes Monorepo Architecture -ZenNotes now uses a single monorepo so the desktop app, self-hosted web app, and future hosted deployment can share one product core instead of drifting across separate repositories. +Desktop and web share one product core in this repository. iOS, Android, the TUI, +and Laravel Cloud have separate repositories. The Go self-hosted server currently +lives here and will move to `ZenNotes/znserver` after its build and release inputs +are independent. See [the ecosystem migration plan](specs/ecosystem-boundaries-and-repository-plan.md). ## Layout @@ -8,12 +11,13 @@ ZenNotes now uses a single monorepo so the desktop app, self-hosted web app, and apps/ desktop/ Electron shell, preload, updater, packaging web/ Vite/PWA shell and HTTP bridge - server/ Go server for self-hosted and hosted deployments + server/ Go server for self-hosted deployments + share-viewer/ Public read-only renderer packaged for Laravel packages/ app-core/ Shared React application and renderer logic bridge-contract/ Typed runtime contract between UI and host - shared-domain/ Shared types and note/task/view models - shared-ui/ Reusable UI primitives (small today, can grow later) + shared-domain/ Portable domain functions and compatibility type exports + shared-ui/ Reserved workspace, currently an empty export tooling/ scripts/ Shared tooling hooks and migration scripts ``` @@ -26,7 +30,14 @@ Platform-specific code should stay in the app shells: - `apps/desktop` for Electron-only concerns such as windows, menus, updater, packaging - `apps/web` for browser/PWA bootstrapping -- `apps/server` for HTTP/WebSocket serving, vault access, and deployment/runtime config +- [ZenNotes/znserver](https://github.com/ZenNotes/znserver) for HTTP/WebSocket serving, vault access, and deployment/runtime config + +The Go server has its own repository. It develops and tests without frontend +assets; its distribution builds embed the browser artifact that this repository +publishes (`web-*` releases) and pin in its manifest. This repository's browser +harness, perf runs, and `dev:web-stack` use the release pinned in +`tooling/server-release.json`, an explicit `ZENNOTES_SERVER_BINARY`, or a +checkout in `ZENNOTES_SERVER_DIR`. ## Bridge Contract @@ -45,13 +56,59 @@ Each runtime installs its own implementation: - Electron preload installs the desktop bridge - The web client installs the HTTP bridge backed by the Go server +- The mobile repositories install their native adapters + +### Dependency direction + +`app-core` depends on `shared-domain` and `bridge-contract`. Domain functions +depend on contracts. Contracts depend only on their own source files and standard +browser types, never on domain implementations or Node globals. + +The contract compiler uses `noResolve` and an empty `types` list to enforce this +closed source set. Existing domain type imports remain available through +compatibility re-exports. The portable preference key list has one definition in +the contract package and remains available from `shared-domain/app-config`. + +See TypeScript's [noResolve](https://www.typescriptlang.org/tsconfig/noResolve.html) +and [types](https://www.typescriptlang.org/tsconfig/types.html) documentation. ## Deployment Modes -ZenNotes should ship as: +Runtime ownership is: - desktop: `apps/desktop` -- self-hosted: `apps/web` + `apps/server` -- hosted: the same `apps/web` + `apps/server` stack, with auth/storage additions - -Hosted mode is a deployment mode of the same web stack, not a separate frontend. +- self-hosted: `apps/web` + the Go server from ZenNotes/znserver +- Cloud: the separate private `ZenNotes/website` Laravel application owns + accounts, billing, vault revisions, storage authorization, and publishing + +Cloud browser editing is planned. It will share the editor source through a +browser adapter for Laravel; the existing Go HTTP bridge does not already provide +that integration. + +The self-hosted browser can now be packed as a pinned archive with +`npm run artifact:web`. The Go-only `cmd/prepare-web` verifies its manifest and +assets before an `embed_web` build. A local extraction rehearsal builds with the +destination module name and no frontend source. Production publishing remains in +this repository until the remaining migration gates pass. See +[the rehearsal guide](server-extraction-rehearsal.md). + +## Other repositories and release boundaries + +| Repository | Owns | Current cross-repository dependency | +| --- | --- | --- | +| `ZenNotes/zennotesios` | iOS shell, iCloud/filesystem, native lifecycle and integrations | Exact vendored core/contract/domain archives, public exports, native lifecycle fixtures | +| `ZenNotes/zennotesandroid` | Android shell, storage access framework, native lifecycle and integrations | Exact vendored core/contract/domain archives, public exports, native lifecycle fixtures | +| `ZenNotes/tui` | TUI, standalone `zn` CLI and MCP, local/remote backends | Self-hosted HTTP API and independently implemented vault rules | +| `ZenNotes/website` (private) | Laravel Cloud, website, accounts, billing, public shares and publications | Versioned viewer manifest/importer with actual-payload browser tests; local pin awaits clean publication | +| `ZenNotes/znserver` | Selected destination for Go self-hosting | Empty repository at the start of this migration | + +Desktop installers/updater and AUR/Homebrew/Nix desktop packages remain owned by +this repository. The Go binary, Docker publishing, and server-specific Nix inputs +move only after a verified server release and rollback rehearsal. Native releases +and the TUI's GoReleaser/Homebrew distribution remain independent. + +The public share viewer source is restored under `apps/share-viewer`. Its new +artifact is verified against current Laravel payloads; it does not claim byte +identity with the older copied bundle. Laravel retains that legacy bundle and +installs current/retained versioned pins independently. The local candidate has +not been deployed. See [the viewer guide](../apps/share-viewer/README.md). diff --git a/docs/reference/runtime-and-package-map.md b/docs/reference/runtime-and-package-map.md index 82cf066f..01186ef3 100644 --- a/docs/reference/runtime-and-package-map.md +++ b/docs/reference/runtime-and-package-map.md @@ -56,9 +56,11 @@ Important point: The web app does not reimplement the product UI. It mounts the shared UI from `packages/app-core`. -## apps/server +## The Go server (ZenNotes/znserver) -`apps/server` is the Go backend for self-hosted and future hosted modes. +The Go backend for self-hosted and future hosted modes lives in its own +repository, [ZenNotes/znserver](https://github.com/ZenNotes/znserver). It +embeds the browser artifact this repository publishes. Responsibilities: @@ -70,10 +72,10 @@ Responsibilities: - security headers and CORS/origin checks - serving the embedded web bundle -Important scripts: +Important scripts here: -- `npm run dev:server` -- `npm run build --workspace @zennotes/server` +- `npm run dev:server` runs the pinned release (or a checkout via `ZENNOTES_SERVER_DIR`) +- `npm run server:binary` prints the resolved server binary path ## packages/app-core @@ -166,7 +168,7 @@ As a rule: - runtime contract -> `packages/bridge-contract` - desktop-only shell concerns -> `apps/desktop` - browser-only bootstrapping -> `apps/web` -- server-side vault/network/security behavior -> `apps/server` +- server-side vault/network/security behavior -> ZenNotes/znserver ## Related docs diff --git a/docs/server-extraction-rehearsal.md b/docs/server-extraction-rehearsal.md new file mode 100644 index 00000000..07478bef --- /dev/null +++ b/docs/server-extraction-rehearsal.md @@ -0,0 +1,137 @@ +# Local Go server extraction rehearsal + +Status: completed. On September 16, 2026 the server history was extracted to +[ZenNotes/znserver](https://github.com/ZenNotes/znserver) (main `975412e8`), +its release `v2.50.4` was published, and the Docker channel moved to that +repository's publisher (`adibhanna/zennotes:2.50.5`). `apps/server` and the +rehearsal tooling were removed from this repository afterwards. The rest of +this document records how the rehearsal was run and what it proved. + +## Reproduce the source and artifact boundary + +From the main repository: + +```sh +npm run artifact:web +node tooling/scripts/rehearse-server-extraction.mjs +``` + +The second command creates a new temporary directory and prints its location. It +copies Go source, fixtures, license, and the explicit browser artifact inputs. It +rewrites the module and Go imports to `github.com/ZenNotes/znserver` in that copy +only. It does not initialize a repository, change the original module, rewrite Git +history, remove source, or contact the destination repository. + +The retained tree contains `source/web-artifact/manifest.json` and its adjacent +archive, so subsequent builds need neither the main checkout nor its artifact +directory. `provenance.json` records original/extracted file hashes and artifact +hashes. Source symlinks and npm workspace machinery are not copied. +Destination-specific Go/Docker/Nix/CI/release files come from +`tooling/server-repository`; they are inert templates until copied into a +reviewed destination checkpoint. `release.json` preserves the current server +version and Go vendor hash. + +The rehearsal runs `go vet ./...`, `go test ./...`, and an API-only build before +installing browser assets. It then runs the Go artifact importer, tagged bundle +tests, and an embedded production build. `GOWORK=off` prevents an ambient Go +workspace from satisfying missing source dependencies. + +For local dirty candidates, the command explicitly enables `-allow-dirty`. A +release build must use a reviewed clean-source manifest without that option. +See [the server build guide](../apps/server/README.md) for importer constraints. + +To run the existing browser benchmark against the exact resulting binary: + +```sh +ZEN_PERF_WEB_SERVER_BINARY= \ + ZEN_PERF_WEB_NOTES=300 npm run perf:web-runtime +``` + +It seeds a temporary vault and browser profile. With a prebuilt binary selected, +the benchmark does not rebuild or restage the browser bundle. + +## Evidence and remaining gates + +Local verification covers: + +- Full workspace typecheck and test suites; standalone contract/domain tarballs. +- Go tests and race checks for the importer, HTTP API, and vault operations. +- Strict manifests, checksums, unsafe archive paths/links, HTTPS redirects, + immutable archive identity, and existing-output protection. +- A fresh source tree using the destination module name, built with Go alone. +- The compiled browser app: login, note read/edit, exact Unicode save bytes, + asset metadata, reload with the session preserved, and logout at `/` and `/notes`. +- A 300-note browser benchmark against the extracted binary. +- A Go-only Docker candidate built from the copied source and archive, with root + and prefixed HTTP checks for assets, authentication, exact writes, generic-file + metadata, and logout across both route families. The candidate uses a temporary + Dockerfile and local image tag; the published Dockerfile/publisher has not moved. + +The HTTP fixture also covers legacy root-level API routes. Both route families use one session +cookie scoped to `/`, allowing cached clients to change API paths across +upgrades. Login, logout, and rotation expire the old `/api` cookie to avoid +duplicate cookies. Session flags and bearer authentication remain in place. Generic embedded file targets follow desktop metadata behavior. + +Both native package consumers, the maintained viewer/Laravel boundary, and TUI +fixtures now pass local integration gates. Go-only Docker images for arm64/amd64 +pass root and `/notes` runtime checks. A separate disposable Nix container builds +the Go-only candidate on aarch64-linux and verifies embedded HTML and authenticated +exact Unicode read/write. Its image is +`nixos/nix@sha256:7a007c766426c1877758ddc5cb87a965ac131fc78c582ce0083d922d51ae945c`, +with nixpkgs `26.05.3494.714a5f8c4ead` and Go 1.26.4. No host Nix installation was +required. The Nix importer runs in `postConfigure`, after vendoring; `preBuild` +also runs during dependency collection and must not invoke the importer there. + +A local released-v2.50.4 -> candidate -> released-v2.50.4 sequence at both URL +mounts preserves exact fixture bytes and permits continued writes after rollback. +The candidate includes the released v2.50.4 source, including asset-reference +rewrites. Its web artifact and Go source were rebuilt after reconciliation; +[the integration record](v2.50.4-boundary-integration.md) records the exact commit +and checks. Rehearsal release metadata reads the server package version while +retaining the established Go dependency hash. Remote CI and macOS Nix have not run. Live account-backed Cloud/iCloud acceptance and Cloud browser access have +separate gates in the [ecosystem plan](specs/ecosystem-boundaries-and-repository-plan.md). + +Full reports are in [the local validation bundle](../dist/ecosystem-boundary-validation/VALIDATION.md). + +## External server during browser development + +The existing `dev:web-stack` command can run an extracted checkout or binary: + +```sh +ZENNOTES_SERVER_DIR=/path/to/znserver npm run dev:web-stack +# Or choose the compiled binary, without a Go checkout: +ZENNOTES_SERVER_BINARY=/path/to/zennotes-server npm run dev:web-stack +``` + +Set only one option. The Vite proxy still targets localhost:7878. Keep dedicated +test config/vault/auth variables for runtime verification. With neither option, +the main repository's existing Go source remains the compatibility fallback. +The external-binary entrypoint has a local startup/health check. + +## History and release ownership, after local validation and approval + +The current rehearsal deliberately copies uncommitted working source. Actual history +extraction must wait until the intended source checkpoint is approved and committed. +A non-mutating dry run is complete: git-filter-repo 2.47.0 processed 979 commits, +including 160 touching `apps/server`, with original and scratch refs unchanged. +The dry run writes filtered fast-export text without running fast-import, so it +creates no replacement commits. The scratch clone then fetched v2.50.4 read-only +for the upstream comparison; its refs are no longer the initial dry-run snapshot. +Then use a disposable clone and filter `apps/server/` to the new repository root, +following the [GitHub extraction guide](https://docs.github.com/en/get-started/using-git/splitting-a-subfolder-out-into-a-new-repository). +Keep the original repository and release tags untouched. Module renaming and +destination-specific build files should be explicit follow-up changes in that copy. + +Review distribution ownership before enabling any new publisher: + +| Existing owner | Destination responsibility | Compatibility to preserve | +| --- | --- | --- | +| Main `.github/workflows/docker-publish.yml` and `Dockerfile` | Server Docker build and publishing | `adibhanna/zennotes`, amd64/arm64, tags, UID 65532, `/workspace`, `/data`, port 7878, binary entrypoint, configuration variables | +| Main `packaging/nix/package-server.nix` | Server source and pinned browser inputs | Package/binary name, Go vendor hash, supported Linux/macOS builds | +| Main server workspace and development scripts | Documented server checkout or installed binary | Convenient web-stack development, existing CLI/configuration behavior | +| Main web build | Immutable browser artifacts and protocol fixtures | Complete assets, reviewed manifest, retained previous artifact for rollback | + +Do not move desktop installers, updater, desktop Nix, AUR, Homebrew, mobile, or TUI +publishing into the server repository. Switch one server channel only after its +candidate installation, upgrade, and rollback checks pass. Remove the original +server source only after a verified destination release and channel cutover. diff --git a/docs/specs/desktop-cli-tui-migration.md b/docs/specs/desktop-cli-tui-migration.md new file mode 100644 index 00000000..f2eecb7d --- /dev/null +++ b/docs/specs/desktop-cli-tui-migration.md @@ -0,0 +1,394 @@ +# Replace the desktop CLI with the standalone Go CLI and TUI + +**Status:** Implemented locally on September 16, 2026, with a verified local Go +candidate. Production activation remains disabled in `apps/desktop/terminal-release.json`. +No commits, pushes, release uploads, installed user CLI changes, or MCP client +configuration changes were made for this implementation. + +## Pre-release follow-up, September 16 + +- Linux `statx` now reads birth time where supported. Both hosts preserve the + original note date in `.zennotes/note-metadata/.metadata.json` before + atomic saves. Renames, folder moves, Trash/restore and deletion maintain the + sidecar; malformed metadata blocks a save before touching Markdown. +- Desktop and the retained Node/MCP surface read the same format. Cloud client + and Laravel allowlists accept the exact metadata suffix. Deploy that server + change before the desktop update. Older clients still use filesystem dates. +- Installer receipts authorize automatic repair only for the recorded path and + exact link target. Unrecorded dangling AppImage/moved-app shortcuts require a + Settings review, expiring main-issued token, unchanged-link check and backup. +- Real Linux arm64 AppImage UI testing passed fresh install, automatic upgrade, + reviewed repair, foreign PATH protection and a shortcut-change race. Go keeps + working after the extraction disappears and preserves the date on append. + Final checks also cover a root-owned, non-writable directory: unavailable + elevation leaves the shortcut unchanged and reports the error inside the card. +- Developer ID signed macOS package and embedded Go pass signature verification + and launch into the real renderer. Thirty concurrent app/CLI saves yielded + 643 complete reads and kept the date. Content still uses last-writer semantics. +- Actual Raycast UI listed the isolated vault, archived and restored a note via + Go. The temporary verification extension was uninstalled afterwards. +- Full Go suites pass on macOS, Linux arm64 and emulated Linux x64. Desktop suite: 820 passed, four + platform skips; final focused suite: 47 passed, one Windows skip. All eight + root typecheck tasks pass. Cloud API: 40 tests, 194 assertions. All six Go + archives build; Intel probes pass under Rosetta and Linux emulation. +- Local release branch/worktree: `v0.2.0` at `/tmp/zn-tui-v0.2.0-release`, scoped + to the CLI compatibility changes. It has no commits yet. Publication and the + production desktop pin await explicit commit/push approval. + +Platform limits: Linux proof uses an arm64 Docker VM with Xvfb and AppImage +extraction mode. It does not prove native Hyprland/Wayland, FUSE, or physical Intel +hardware. Linux x64 Electron did not reach a renderer under emulation, so native +x64 GUI verification remains a desktop release gate. Windows archives cross-build; desktop CLI installation remains disabled +there. Local macOS notarization cannot run without Apple credentials and remains +in the desktop release CI gate. The legacy arm64 AppImage runtime in the container +needed `zlib1g-dev`; this is separate from the persistent CLI. + +Detailed evidence: ignored `dist/cli-tui-transition/`. Release handoff: +`docs/releases/cli-v0.2.0/`. + +## Implemented locally + +- Desktop stages a verified native binary, keeps versioned persistent copies under + `/cli/terminal/versions`, and atomically updates `terminal/current`. + `/cli/zn` is the stable managed launcher. +- Existing owned `zn` links upgrade at startup. The historical `resources/zen` + launcher forwards to Go. Homebrew/manual commands keep their own ownership. + Symlinked app folders and macOS `/tmp` aliases are recognized by canonical path. +- `zn` remains help, existing commands retain their shape, and `zn tui` opens the + interactive app. Desktop commands default to `app`; the TUI defaults to + `terminal`. Explicit selectors still win. `zn use` changes the terminal default. +- App mode uses desktop vault/profile names and token precedence. A terminal + credential for the same URL cannot silently replace the desktop token. + Interactive local/server switches retain the chosen workspace source. +- MCP keeps its first successfully opened backend for the process lifetime, just + like the Node server. A failed initial resolution can be retried. Restarting + MCP picks up a changed desktop default. +- macOS atomic note replacement preserves filesystem creation time. Explicit + inbox/root layout settings now win over inferred layout in Go, including + renamed system folders and leftover directories from an earlier layout. +- Settings shows the Go version and update errors with Repair. Help and the + website explain workspace defaults, external installs, and rollback. +- `ZENNOTES_CLI_ENGINE=legacy` selects Node before invocation. Failed Go commands + are never retried through Node. Direct `cli.js` and `mcp.js` entry points remain + on the retained Node implementation during this transition. + +## Earlier migration verification + +This section records the initial candidate before the pre-release follow-up above. +The Developer ID, Linux creation-date and installer results above supersede its +corresponding limitations. + +- Full desktop suite: 798 passed, 4 skipped. The later canonical-path regression + and runtime suite: 16 passed, 1 platform skip. Root typecheck passes. +- Full Go test suite and `go vet ./...` pass, with failing-before-fix regressions + for workspace names, credential precedence, TUI switching, macOS birth time, + and explicit vault layout with remapped system folders. +- Sixty command comparisons pass on macOS arm64 and sixty on Linux arm64 in a + container: inbox, root, and remapped-folder vaults. They cover read/search, + tasks, append/write, capture, folder rename, trash/restore, comments and database + rows. Resulting files match; exact piped Markdown bytes are asserted. + The comparison deliberately normalizes generated UUIDs and operation timestamps. + It does **not** establish creation-time parity on Linux. +- Actual stdio MCP subprocesses read/write/append identical content, select the + desktop vault despite a different terminal default, and retain their initial + vault after the desktop config changes. The terminal vault stays untouched. +- Actual Electron UI tested automatic old-link migration, Settings uninstall and + reinstall, foreign off-PATH handling, and version display using isolated HOME, + configuration and user-data directories. +- Packaged macOS arm64 app tested old resource-link upgrade, explicit Node + rollback and `zn tui` in a real PTY. The persistent CLI still works after app + exit with the app bundle temporarily unavailable. +- The package and embedded Go binary pass local code-signature verification. + Runtime testing uses ad-hoc signing with hardened runtime disabled because + ad-hoc binaries have no common Developer ID team. Production Developer ID + signing and notarization remain a release gate. +- Twelve importer/launcher checks cover archive checksum failure, wrong target, + disabled-pin cleanup, persistent launch, argument/stream preservation and no + automatic retry. Unactivated candidates cannot bypass verification through the + resource launcher. Website Blade compilation and six DocsPage tests pass. + +Evidence lives in ignored `dist/cli-tui-transition/`. Previous assessment evidence +below describes the earlier candidate and is retained as migration context. + +## Release gates and retained behavior + +1. Approve the scoped Go commit/push, publish `v0.2.0`, then download the actual + four desktop-target archives and verify checksums. Pin the exact source + commit, version, protocol and uploaded archive hashes in + `apps/desktop/terminal-release.json`. Normal packaging remains on Node until + that manifest is filled. Never use local rehearsal hashes as release hashes. +2. Deploy the Laravel creation-metadata allowlist before the desktop update. + Keep the sidecars with the vault. Older apps and manual file moves do not + maintain the new metadata format. +3. Run notarization in the existing signed desktop CI release path. Local + Developer ID signing and runtime verification passed; notarization did not run. +4. Complete the Linux x64 GUI check on a native runner. The emulated CLI passes, + but the emulated AppImage did not reach Electron startup/CDP. +5. Retain Node and direct MCP/CLI entry points for the first transition release. + Explicit Node rollback on AppImage requires its original resource mount. + Runtime retirement is a later change after published upgrade coverage. + +## Local build and repeatable checks + +Build the candidate in the TUI checkout, then from the desktop repository: + +```sh +node tooling/scripts/terminal-artifact.mjs --local-binary /absolute/path/to/zn --license /absolute/path/to/tui/LICENSE +node tooling/scripts/verify-terminal-compat.mjs /absolute/path/to/zn +npm run test:terminal +npm run test:run --workspace @zennotes/desktop -- src/main/cli-install.test.ts src/main/terminal-runtime.test.ts +npm run typecheck +npm run build --workspace @zennotes/desktop +``` + +Local packaging requires both `ZENNOTES_TERMINAL_ARTIFACT_DIR` pointing to +`apps/desktop/build/terminal` and `ZENNOTES_ALLOW_LOCAL_TERMINAL=1`. CI rejects +local overrides. Nothing from the sibling TUI checkout is imported by desktop. +A release manifest with `release: null` removes stale staged artifacts during +packaging so a local candidate cannot accidentally survive into a normal build. + + +## Recommendation + +Make `ZenNotes/tui` the owner of the `zn` command line, terminal UI, and standalone +MCP server. Desktop should consume a pinned binary release and own only its +installation, update, and desktop integration. Keep `zn` as the public command. + +The user experience remains Settings > CLI > Install. Existing commands such as +`zn list`, `zn capture`, and `zn mcp` continue to work; `zn tui` becomes available +through the same installation. Bare `zn` should continue showing help. + +Bundle the selected Go binary with the app. Installation should copy it into a +persistent, user-owned directory and create the shell shortcut there. Updates +should arrive with desktop releases, using the exact tested TUI version. Homebrew +and manual installations continue to follow their own update channels. + +## What exists today + +| Surface | Current implementation | Migration consequence | +| --- | --- | --- | +| Desktop install | `apps/desktop/src/main/cli-install.ts` creates a `zn` symlink to `resources/zen` | Preserve command name, PATH discovery, managed ownership, and uninstall behavior | +| Bundled launcher | `apps/desktop/build/zen` runs Electron with `ELECTRON_RUN_AS_NODE=1` and `cli.js` | Replace the runtime dependency with the Go executable | +| Packaging | `apps/desktop/package.json` copies `zen`, `cli.js`, and shared chunks outside ASAR | Add native artifacts by target architecture; remove only obsolete resources after auditing consumers | +| MCP setup | `mcp-integrations.ts` prefers managed `zn mcp`, otherwise invokes Electron and `mcp.js` | Existing managed CLI configs will switch with the launcher; direct MCP configs need their own migration | +| Raycast | Discovers `zn`, with a legacy `zen` fallback, and consumes CLI JSON | Include Raycast commands in the compatibility gate | +| Go tool | `ZenNotes/tui` owns CLI, TUI, MCP, vault logic, and remote adapters | Reuse this implementation without copying its source into desktop | +| Platforms | Desktop Settings installs CLI on macOS/Linux; Go releases also include Windows | Migrate existing supported installers first; implement Windows PATH installation separately | + +The latest published TUI release observed during this assessment was +[`v0.1.0`](https://github.com/ZenNotes/tui/releases/tag/v0.1.0), with macOS, Linux, +and Windows archives for amd64 and arm64 and a checksums file. Local tests below +used the current working tree, not those downloaded archives. Publication must +pin a release that actually contains the compatibility work. + +## Local evidence + +The desktop CLI was run from its built `out/main/cli.js`. A Go candidate was built +with `go build -trimpath -ldflags='-s -w' -o ./cmd/zn`. Both ran +against the same disposable vault and isolated configuration. + +- Fourteen of fifteen command comparisons had identical exit status, parsed JSON + or text output, and stderr. Checks covered list, read, search, title search, + backlinks, folders, tags, tasks, empty databases/comments, vault info/list, and + an unknown command. JSON comparisons ignored object-key ordering, not fields. +- The differing case was a missing note: both exited 1 with no stdout; Node and + Go worded the filesystem error differently. +- Writing a note through stdin preserved identical UTF-8 bytes, including Unicode, + trailing spaces, and the final newline, in both implementations. +- Write responses exposed a metadata difference: the Go atomic replacement changed + the fixture's creation timestamp, while the desktop write retained it. Go also + reports integer milliseconds where Node can report fractional milliseconds. +- Real stdio MCP initialization succeeded for both. All 34 tool names and input + schemas matched after removing descriptive schema text. `read_note` returned + the same content. This does not prove parity for every tool's behavior. +- `zn tui` opened in a real PTY, displayed the fixture vault, notes, and task, and + exited successfully through `:q`. +- The stripped local macOS arm64 Go executable was approximately 24.2 MiB before + archive compression or distribution signing. + +Reports and probe scripts are in the ignored +`dist/cli-tui-migration-assessment/` directory. `assessment.json` records source +HEADs, the candidate checksum, and validation limits. Both source checkouts had +local boundary work, so these are working-tree results rather than release proof. + +## Compatibility decisions before switching + +### Preserve the selected vault + +This is a confirmed behavior difference, not just a possible risk. With desktop +using vault A and `workspaces.toml` selecting vault B, `zn list --json` from the +desktop CLI listed A; the Go CLI listed B. `zn use app` made Go follow A again, +but running that during migration would overwrite the user's terminal preference. + +Recommended contract: + +1. Explicit `--server`, `--vault`, and existing environment overrides retain their + precedence. +2. A migrated desktop-managed command keeps following the desktop workspace until + the user deliberately changes that command's default. +3. Selecting a different vault inside the TUI must not silently retarget existing + scripts, Raycast, or desktop-configured MCP clients. +4. Standalone terminal users retain their existing saved default. +5. Desktop-managed MCP configuration gets an explicit follow-desktop option in the + Go resolver. It must remain clear which workspace an agent will operate on. + +Implement the selection policy in the Go tool and expose a small documented +option to the desktop launcher. Separate the interactive TUI preference from the +migrated command default. Do not have a shell wrapper reinterpret every command +or rewrite `workspaces.toml` during installation. + +Keep the existing remote credential boundary. Neither CLI automatically decrypts +Electron's OS-protected server token. Continue supporting explicit token/env +configuration and the Go tool's own credential store; installation must not export +the desktop token into plaintext. + +### Preserve output, mutations, and integrations + +Add a repeatable differential gate against the shipped desktop CLI, using fresh +fixtures for each implementation. Compare JSON fields, array ordering, stdout, +stderr, exit codes, and resulting files. Normalize only explicitly identified +nondeterministic values such as generated IDs and operation timestamps. + +Include stdin pipelines, argument quoting, custom system-folder paths, root mode, +task IDs and toggles, comments, databases, lifecycle commands, invalid paths, +remote errors, and concurrent edits. Resolve creation-time preservation before +calling write behavior compatible. Keep useful errors consistent; exact OS error +wording can be a documented exception rather than a reason to reproduce Node. + +Exercise MCP reads and writes and its long-lived workspace behavior. Tool-schema +equivalence alone is insufficient. Verify Raycast with the selected Go artifact. + +`zn open` needs a reliable way to locate the originating desktop installation, +including moved apps and AppImages. The Go tool already supports +`ZENNOTES_APP_PATH`; use a maintained desktop association rather than an ephemeral +mount path or an assumption that the app lives under `/Applications`. + +## Artifact and installation boundary + +### Build-time contract + +Add a small checked-in manifest containing the TUI repository, release version, +source commit, compatibility revision, and archive URL/SHA-256 for each supported +OS/CPU pair. Map Electron `x64`/`win32` to Go `amd64`/`windows` explicitly. + +Desktop packaging verifies the pin, extracts only the expected executable and +license, and includes the binary outside ASAR. Cache by checksum; reject missing, +wrong-architecture, or mismatched artifacts. Release builds must not silently fall +back to an unrelated `zn` on PATH or fetch mutable `latest`. + +Use a local artifact override for development and testing. Keep it distinct from +the publishable release manifest. No sibling source checkout should be required +for a clean desktop build. + +The repository uses electron-builder 26. Its `extraResources` supports native +resources, and `mac.binaries` identifies additional executables for signing. +Stage the binary before signing and test the signed installed copy. A macOS signing +step changes executable bytes, so distinguish upstream download checksums from any +post-signing checksum used to verify installation copies. +[Contents documentation](https://www.electron.build/v26/docs/contents/), +[macOS signing options](https://www.electron.build/v26/docs/mac/). + +### Persistent managed installation + +Use a versioned directory under the desktop's user-data CLI area, for example: + +```text +/cli/releases/--/zn +/cli/current -> releases/-- +/cli/zn stable managed launcher +/zn -> /cli/zn +``` + +The launcher supplies only documented desktop context and execs the selected Go +binary, preserving stdin, stdout, stderr, exit codes, and terminal signals. + +Copy into staging, verify integrity and executable identity, run a version probe, +then atomically activate the selected version. Retain the last working version +for rollback. App startup only updates an installation already owned by desktop; +it must not install a command for someone who never requested it. Failed updates +leave the existing command usable and report the failure in Settings. + +This persistent location matters particularly for AppImage: its application files +live under a temporary mount. A PATH shortcut must not depend on that mount +remaining available after the app exits. +[AppImage architecture](https://docs.appimage.org/reference/architecture.html). + +### Existing installation ownership + +- Recognize the exact prior ZenNotes wrapper paths and recorded managed install + metadata, including stale links from an old bundle or AppImage. Do not decide + ownership from a broad substring such as `ZenNotes.app` alone. +- Replace a managed shortcut in place; preserve its PATH location. Use atomic + link replacement and recheck ownership before replacing or removing anything. +- Keep the resource named `zen` as a compatibility launcher during migration. + The public command remains `zn`; never touch a foreign `zen` browser command. +- Discover which `zn` actually wins in login-shell PATH order. Report both a + managed installation and a shadowing installation when they coexist. +- Recognize Homebrew/manual installations as externally managed. Offer usage and + update guidance without overwriting, uninstalling, or shadowing them. +- Elevated changes happen only through the explicit install/repair flow. Startup + migration must not trigger an administrator prompt. +- Show installed version, bundled version, ownership, and path in Settings. Keep + UI text focused on `zn`, `zn tui`, install/update/repair, and useful errors. + +## Delivery order + +### Existing users: update desktop and keep using `zn` + +The default migration should require only a normal desktop update and launch. +Users should not need to uninstall the old CLI, install Homebrew, edit PATH, or +change their scripts. + +The existing shortcut points at a resource named `zen` inside the application. +Keep that exact resource path and replace its implementation with a compatibility +launcher. When an app updates in place, the old shortcut reaches the updated +launcher without editing the PATH directory. This also avoids requiring admin +access solely to replace a shortcut in `/usr/local/bin`. + +On first launch after updating, stage and verify the managed Go installation. +Activate it only after the compatibility gate is satisfied. The old resource +launcher delegates to that installation; writable managed shortcuts can also be +repointed to the persistent launcher. Preserve the working legacy implementation +when staging or activation fails. An app move or stale AppImage link may still +need an explicit Repair action when its original PATH directory is not writable. + +Preserve direct desktop-generated CLI/MCP entry paths with forwarding adapters +until those configurations have a supported migration route. Already-running MCP +processes finish on their existing runtime; newly started processes use the +selected implementation. Never rewrite unrelated client configuration. + +Keep the default vault, existing commands, flags, pipe behavior, JSON output, and +exit codes compatible. Bare `zn` stays help; `zn tui` is the new opt-in interface. +Announce the new terminal UI in desktop release notes or Settings, never by +injecting migration notices into command output or MCP stdio. + +Retain an explicit rollback to the old runtime for the initial transition release. +Remove that runtime after upgrade testing and the compatibility gates justify it; +the tiny launcher at the historical path can remain after the old engine is gone. + +### Implementation slices + +| Slice | Owner | Completion gate | +| --- | --- | --- | +| 1. Compatibility contract and Go fixes | TUI, shared fixtures | Differential checks pass; workspace defaults and creation timestamps resolved; real TUI and MCP exercised | +| 2. Pinned artifact consumption | Desktop tooling, TUI release | Clean desktop packaging obtains verified binaries without sibling source; per-target version/architecture checks pass | +| 3. Managed installer and migration | Desktop | Fresh install, legacy upgrade, app move, app exit, rollback, uninstall, and foreign-install cases pass in isolated environments | +| 4. Settings, MCP, Raycast, and docs | Desktop and website | Actual app Settings installs `zn`; terminal UI and scripts work; managed MCP and Raycast hit the intended vault | +| 5. Retire the TypeScript runtime | Desktop | Packaged verification passes; old MCP entry configurations have a supported compatibility route; obsolete CLI resources can be removed safely | + +Keep the old implementation as a comparison oracle during development. A failed +Go command must never automatically retry a write through the old CLI: the first +attempt may already have changed files. Rollback selects an implementation before +an invocation, rather than retrying a possibly completed mutation. + +Do not delete all of `src/mcp` together with `src/cli`: desktop main and other +features import vault operations from there. Audit imports and extract reusable +desktop helpers before removing the old command entry and MCP launcher. + +Final acceptance needs packaged macOS and Linux runs, including AppImage after +desktop exits, installation offline, PATH conflicts, active MCP sessions during +updates, and desktop/TUI simultaneous edits. Windows installation can then be +added with its own persistent executable and user-PATH handling. + +The implementation and remaining release gates are recorded at the top of this +document. This earlier assessment remains the rationale for the transition. diff --git a/docs/specs/ecosystem-boundaries-and-repository-plan.md b/docs/specs/ecosystem-boundaries-and-repository-plan.md new file mode 100644 index 00000000..fd015df4 --- /dev/null +++ b/docs/specs/ecosystem-boundaries-and-repository-plan.md @@ -0,0 +1,521 @@ +# ZenNotes ecosystem boundaries and repository plan + +**Status:** Local boundary implementation and cross-client validation are complete on `refactor/ecosystem-boundaries`. Publication, account-backed staging validation, destination import, and channel cutover remain gated. Nothing has been staged, committed, pushed, or deployed by this work. +**Date:** September 15, 2026 + +## 1. Recommendation + +Keep desktop, web, and their shared editor in `ZenNotes/zennotes`. Keep the existing iOS, Android, TUI, and private Laravel repositories. Make the Go self-hosted server independently testable and releasable inside the current repository, then extract it into one new public repository, `ZenNotes/znserver`, the destination selected by the maintainer. + +The intended result is **six repositories, with explicit package, artifact, and API boundaries**. Five contain the existing product source; the maintainer has also created the empty `ZenNotes/znserver` destination. A separate repository for every shared library would add release coordination before delivering useful isolation. + +### Execution record + +| Slice | Local status and evidence | +| --- | --- | +| P0 ownership and provenance | Six repository owners documented. Historical viewer `c534a1d0` reproduced 67 of 73 old payload files; six differences prevent claiming exact old-byte provenance. The maintained replacement has separate browser proof; the old Laravel bundle remains available. | +| P0/P3 shared behavior | The same exact-byte task fixtures pass in TypeScript, Go server, and TUI, including near-midnight dates in Los Angeles and Auckland. Go/TUI consume versioned fixture copies with SHA-256 provenance. | +| P1 dependency graph | Contracts own portable types and `ZenPlatform`; domain depends on contracts, never the reverse. Compatibility exports remain. CI rejects Node globals and reverse imports. Pure rename/demo helpers belong to shared-domain. | +| P1 public host APIs | Navigation, notes/batches, folder/database actions, tasks, settings, commands, dialogs, immutable shell/workspace observations, editor commands/attachments/geometry, and host capabilities are implemented. Public exports do not expose store or CodeMirror internals. | +| P1 lifecycle guarantees | Mutations drain pending writers and protect late edits. Vault transitions invalidate captured host contexts monotonically, including cancelled/failed transitions. Native vault moves reserve save/move/reopen/rollback as one operation; failed rollback enters recovery state. | +| P2 portable packages | Core `2.50.4-core.h8a09555824b619b5` and its exact companion packages pass standalone nested installs, typechecks, Vite 6 and Vite 8 builds, and 50 real-browser checks per version. Assets, WASM, fonts, and React/CodeMirror/Lezer identity are verified. | +| P2 Android | Source cloning/private imports removed. A clean source-only consumer installs vendored immutable packages and passes 138 tests, types, web/native builds, four instrumentation tests, 20 emulator runtime checks, and three cold-start checks. SAF absence, directories, revoked access, provider failure, and exact bytes have native provider coverage. | +| P2 iOS | Same package boundary, 102 tests, clean web/native simulator builds, 20 runtime and three cold-start checks. Native preferences initialize before the editor; whole-vault rename/reopen and stale attachment contexts are covered. Existing unrelated Xcode project edits remain unchanged. | +| P3 TUI | Task and HTTP fixtures adopted without changing runtime ownership. Root/prefixed HTTP contracts pass against the actual Go binary; all TUI tests, vet, and build pass. | +| P3 Go/web artifact | Go tests need Go alone. Strict Go importer verifies protocol, source, archive/file hashes, tar paths/types, and immutable output. Web `2.50.4-web.hffc8c055ae2667f2` builds into the extracted module without Node or frontend source. | +| P3 Laravel/viewer | Viewer `2.50.4-viewer.h475ea70770234498` renders actual Laravel share/publication payloads. Seventeen focused Pest tests (141 assertions), eleven importer tests, and six Chrome cases pass after the v2.50.4 integration. Static license, normal document scrolling, safe fallback, published themes, and lazy diagrams are verified. Unsafe interactive plot libraries are excluded from the public bundle. | +| P3 artifact retention | Laravel installs current and explicitly retained manifests on every fresh deployment. Old lazy assets do not depend on a persistent build filesystem. Invalid/dirty pins fail normal release installation; the current candidate requires explicit local flags. | +| P4 source/history rehearsal | Fresh extracted source uses `github.com/ZenNotes/znserver`. A git-filter-repo 2.47.0 dry run processed 979 commits (160 touching server paths) without changing original or scratch refs or creating rewritten commits. | +| P4 distribution rehearsal | Go-only Docker arm64/amd64 builds pass root and prefixed auth/assets/exact writes/logout. Released v2.50.4 -> candidate -> released v2.50.4 preserves exact fixture bytes on both mounts. Go-only Nix arm64 Linux build and authenticated runtime read/write pass. | +| Release preparation | Candidate CI, manual draft artifact releases, and destination Go/Docker/Nix/binary release templates are prepared and actionlint-checked. Dirty-source release rejection and local release assembly are verified. No publisher was enabled or dispatched. | + +[Consolidated local evidence](../../dist/ecosystem-boundary-validation/VALIDATION.md) +contains manifests, native/browser reports, screenshots, and logs. The final +workspace check passes all eight typecheck tasks. The full source suites pass +4,806 tests with five existing skips; the desktop build and CLI-without-node_modules +check pass. The production dependency audit reports no advisories. The full Laravel suite +passes 793 tests (6,272 assertions); its deployment safeguard still requires all +quality gates unconditionally while retaining failure evidence. + +The native runtime fixture verifies exact Unicode/trailing-space persistence, +Find, tasks, attachments, note rename, Trash/Restore with comments, whole-vault +rename/reopen, stale context rejection, and cold start. Real account-backed +Cloud sync and iCloud entitlements were not exercised; they require an isolated +staging account/device. No personal or production vault was used. + +The npm registry has no authenticated publishing identity on this machine; +`@zennotes` scope ownership remains unverified. Immutable archives vendored in the +mobile repositories are the interim transport, so clean mobile checkouts do not +need a sibling checkout or unpublished registry packages. Configure registry +publication only after ownership is verified. GitHub draft artifact publication +is prepared separately and remains approval-gated. + +The current branch began at v2.50.3 and now includes the released v2.50.4 source +from `850cf5f8a7f7e10d3f47df1c5732209d8e0c2dcc` through a local three-way +reconciliation. Git HEAD and the pre-existing index remain unchanged. See the +[integration record](../v2.50.4-boundary-integration.md). Read-only release inventory +shows desktop v2.50.4, Android v1.1.20, and TUI v0.1.0; iOS has no GitHub releases. Checkout iOS +version 1.9.10/build 21 is not proof of App Store availability. Preserve all existing +protocol routes and compatibility exports until an installed-client support policy +is explicitly approved. A newer release is not permission to deprecate older apps. + +See [the publication/cutover runbook](../boundary-release-cutover.md) for review +groups, release ordering, retained assets, and rollback. + +### Remaining work in order + +1. **Approve a source checkpoint.** The exact released v2.50.4 changes have been + reconciled locally, and affected source/package/native/browser/distribution + checks pass. Review the existing index and focused migration groups before + committing or aligning branch history with the release. Do not publish the + current dirty candidates as release artifacts. +2. **Run account-backed staging acceptance.** Use dedicated iCloud/Cloud fixtures + to verify real entitlement, account-switch, revocation, and sync behavior. The + local adapter/native storage checks do not replace these account-specific gates. +3. **Publish reviewed clean artifacts and update consumer pins.** Approve commits + and publication; configure protected release environments. Rebuild at approved + source SHAs, validate the clean candidates, publish the reviewed drafts, and + replace local mobile/Laravel/server pins. Laravel's normal CI intentionally + rejects the current dirty pin with no release URL. Run its actual viewer gate + before enabling the install command in production's build configuration. +4. **Import Go history and run destination CI.** The empty `ZenNotes/znserver` + destination is verified, and local extraction/build templates are ready. Actual + rewritten history/import, repository security settings, publisher credentials, + and fresh remote runner checks require approval. Linux Nix runtime is proven; + macOS Nix remains part of the remote/platform matrix. +5. **Switch distribution ownership once.** Publish a verified destination release, + disable the old Docker publisher before enabling the new one, move the server + Nix source pin, then remove the old server source/workspace wrappers. Preserve + desktop packaging. Keep rollback binaries/manifests and test open browser tabs + through the chosen deployment rollout; a single Go binary does not retain all + prior lazy assets automatically. + +These are explicit release/cutover gates, not completed work. The global user rule +requires approval before committing or pushing; the local-only instruction also +precludes publication/deployment now. The migration cannot honestly be called +fully rolled out until those gates pass. + +Cloud browser login and online editing (Phase 5) remain a separate product stream, +as agreed earlier. They are enabled by these boundaries but are not a prerequisite +for the Go split, and are not implemented by this migration. + +### Outcomes + +- Editor changes have one source and can reach desktop, browser, and mobile through deliberate dependency updates. +- Each product can build, test, release, and roll back without checking out another repository's private source tree. +- Native behavior stays with the native application that owns it. +- Existing Markdown vaults, Cloud revisions, published links, command names, and distribution channels keep working. +- Shared behavior is verified across TypeScript, Go, and PHP without forcing them to use the same implementation language. + +This plan does not require a framework rewrite, a vault format migration, new microservices, merging the two backends, or adding Cloud support to the TUI. Keep the current Capacitor versions during the boundary work. + +## 2. Current ecosystem and evidence + +The inspected ecosystem has five clients: desktop, browser, iOS, Android, and TUI. It has two backends: the Go self-hosted server and Laravel Cloud. The website and account portal are also in Laravel. + +| Repository | Current responsibility | Inspected commit | +| --- | --- | --- | +| `ZenNotes/zennotes` | Desktop, web, shared TypeScript, Go server | `a8fc4fc9a954c107b2fe4d6a4433c53702b01b13` | +| `ZenNotes/website` (private) | Laravel website, accounts, billing, Cloud, publishing | `652dbb19b4891f05d421401fb3b108cb4582b2da` | +| `ZenNotes/zennotesios` | Capacitor iOS shell and native integrations | `9971018286cd371f90d7afb882e46d8be3afde50` | +| `ZenNotes/zennotesandroid` | Capacitor Android shell and native integrations | `50c31dcb6ad7799f146cbe45ec6627181cda562f` | +| `ZenNotes/tui` | Go TUI, standalone `zn` CLI, MCP, local/remote adapters | `3ccdc81547780eeee325eb1e4b8b09dc85d17f28` | + +These are source snapshots, not necessarily every deployed version. iOS also has an existing local change to `ios/App/App.xcodeproj/project.pbxproj`; preserve it during future work. + +### Initial coupling inventory (before implementation) + +1. **Mobile builds depend on another repository's layout.** At the initial inventory, both `tooling/prepare-zennotes.sh` scripts cloned the main repository at `.zennotes-commit`. Vite and TypeScript aliases reach into its source. Both mobile vault adapters import `demo-tour-data` and `wikilink-rename` from desktop main-process source. +2. **Shared packages do not yet form a one-way dependency graph.** `shared-domain` depends on `bridge-contract`, while `bridge-contract/src/bridge.ts` imports domain types. The public bridge also exposes `NodeJS.Platform`. +3. **Mobile shells use app-core internals.** They import the store through undeclared package subpaths and directly access state, actions, and editor references. `app-core` currently declares only the `./main` export. +4. **Package builds are source checks, not independently consumable releases.** The core packages are private and build with `tsc --noEmit`. `shared-ui` currently has an empty export, so it is not an existing component library to reorganize around. +5. **Go testing is coupled to frontend preparation.** `run-go-server-test.mjs` prepares the web distribution before running Go tests. Production embeds `web/dist`; Docker builds both stacks from the workspace. Root CI runs a combined production build, although desktop distribution scripts are already scoped to desktop. +6. **The TUI has a good adapter boundary but duplicated rules.** Its `Backend` interface already separates local and remote operations. Its vault types explicitly reference copies of behavior from desktop, shared-domain, and the Go server. +7. **The public share viewer lacks a current reproducible source path.** Laravel tracks a built viewer under `public/vendor/share-viewer`. The main repository has historical viewer source at `c534a1d0`, but no currently tracked `apps/share-viewer` source. Historical source must be verified against the deployed payload and bundle before being treated as the replacement. +8. **Cloud and self-hosted web are different integrations.** Today's web bridge talks to Go. Laravel's sync endpoints expect personal access tokens and active devices. Its browser login is a separate session flow. The existing web service worker also caches same-origin successful GET responses outside its `api/` exclusion, which is unsuitable as a default policy for authenticated Cloud content. + +## 3. Target repository ownership + +| Repository | Owns | Consumes | Release responsibility | +| --- | --- | --- | --- | +| `ZenNotes/zennotes` | Desktop shell; browser shell; shared editor, domain rules, contracts; restored public viewer source | Platform libraries and public dependencies | Desktop installers/updater; core packages; web and viewer artifacts; fixture releases | +| `ZenNotes/znserver` (selected destination) | Go self-hosted API, auth, filesystem access, watcher, server configuration | Pinned self-hosted web artifact; protocol fixtures | Go binary, self-hosted Docker image, server packaging | +| `ZenNotes/website` (existing, private) | Laravel Cloud, account portal, billing, marketing, docs, public shares/publications | Pinned Cloud web and public viewer artifacts | Laravel deployment and Cloud API compatibility | +| `ZenNotes/zennotesios` | iOS app lifecycle, filesystem/iCloud, keychain, native UI, widgets, mobile integration | Versioned core packages and contract fixtures | iOS release | +| `ZenNotes/zennotesandroid` | Android lifecycle, storage access framework, secure storage, native UI, widgets, mobile integration | Versioned core packages and contract fixtures | Android release | +| `ZenNotes/tui` | Terminal UI, standalone CLI/MCP, local and self-hosted adapters | Versioned format/protocol fixtures; optional later pure Go library | TUI/CLI binaries and existing package channels | + +Keep the Laravel repository private. Public contracts and browser assets can be released from the public application repository without exposing Laravel implementation or deployment configuration. Keep marketing and Cloud in the same Laravel application unless a concrete ownership or deployment constraint later justifies separating them. + +### Dependency map + +Solid arrows describe dependencies or communication. Dotted arrows are build artifacts delivered to another repository. + +```mermaid +flowchart TB + subgraph main["zennotes repository"] + contracts["Contracts and behavior fixtures"] + domain["Pure domain functions"] + core["Shared editor and application UI"] + desktop["Desktop shell and native adapters"] + web["Browser shell and adapters"] + viewer["Public share viewer"] + domain --> contracts + core --> domain + core --> contracts + desktop --> core + web --> core + viewer --> domain + viewer -->|"Read-only rendering exports"| core + end + ios["iOS repository"] --> core + android["Android repository"] --> core + ios --> contracts + android --> contracts + web -. "Pinned self-hosted build" .-> server["Go server repository"] + web -. "Pinned Cloud build" .-> cloud["Private Laravel repository"] + viewer -. "Pinned viewer build" .-> cloud + desktop -->|"Cloud sync API"| cloud + ios -->|"Cloud sync API"| cloud + android -->|"Cloud sync API"| cloud + tui["TUI repository"] -->|"Self-hosted API"| server + server --> contracts + tui --> contracts + cloud --> contracts +``` + +Cross-repository source dependencies in this diagram are versioned packages or fixture archives. They are never imports into a sibling checkout. Browser builds execute in the browser and call their chosen backend over HTTP; Laravel and Go serve the assets, not the editor runtime. + +## 4. Code boundaries inside the main repository + +Keep existing package names during migration. Renaming folders is not a prerequisite. + +| Layer | Allowed responsibilities | Boundary rule | +| --- | --- | --- | +| `bridge-contract` | Passive shared types, capabilities, host operation interfaces, separately named wire DTOs | No imports from app-core, domain implementations, Electron, Node, Capacitor, or Laravel source | +| `shared-domain` | Markdown/task/path/rename rules, portable configuration logic, pure transformations | Can import contracts; platform I/O enters through an explicit interface | +| `app-core` | Editor, note navigation, feature UI and orchestration | Calls host interfaces; never directly owns OS paths, native credentials, or server storage | +| `apps/desktop` | Electron IPC, windows, local filesystem, shortcuts, native integrations | Implements host interfaces and validates renderer input at the privileged boundary | +| `apps/web` | Browser bootstrap, HTTP adapters, browser session state, service worker | Chooses self-hosted or Cloud adapter explicitly; reports actual capabilities | +| Mobile repositories | Native adapters and mobile interaction shell | Import declared package exports; no desktop source aliases or arbitrary store mutation | + +### Migration rules + +- Move passive type definitions out of the current type cycle one type family at a time. Leave temporary re-exports in their previous locations to avoid changing every consumer together. +- Replace public Node-specific types with platform-neutral values. Represent operating system and host kind separately where needed. Audit mobile capability reporting instead of assuming that all non-desktop hosts are equivalent. +- Divide the large bridge into coherent interfaces such as vault operations, platform services, and Cloud sync. Preserve the existing `window.zen` facade while migrating implementations; a namespace rewrite is unnecessary. +- Add small, intentional app-core exports for navigation actions, state selectors, editor commands, and host extension hooks. Do not solve deep imports by exporting every store field. +- Give the public viewer a read-only rendering entrypoint with no editor bootstrap, authenticated session, or privileged host dependency. Confirm its required exports during source recovery before creating another package. +- Move pure wikilink rename logic to shared-domain and reusable demo data to a documented package subpath. Keep filesystem traversal and native writes in each host. +- Keep iOS iCloud and Android storage access implementations separate. Preserve Android's asynchronous native preference restoration before importing app-core. +- Preserve lazy loading for heavy editor features and one compatible instance of React, Zustand, and CodeMirror per app. Validate peer dependencies and asset inclusion in real consumer builds. +- Enforce dependency directions and forbidden imports in CI after each family has migrated. Temporary compatibility exceptions need an owner and removal task. + +### Mobile integration findings for the next slices + +The September 15 local review identified these requirements before P2.3/P2.4: + +- Install the Home guard before React or other store subscribers. Preserve native + preference restoration before importing any app-core runtime entrypoint. +- Adopt the verified `@zennotes/app-core/editor` attachment API during native + package migration. Both current native helpers capture a note path before a + picker but reacquire the active editor after asynchronous imports; they also + resolve the active vault separately for each file. Capture the insertion target + and bind the host importer to one vault before the picker. Keep module-owned + picker lifetime and native keyboard behavior, cancel on disposal, and show + recovery guidance for `saved-only` or partially failed imports. +- Adopt the verified `runEditorCommand` and `hasEditorSelection` exports. Keep + CodeMirror views, mutable store state, and pane-layout serialization private. + Retain DOM selection checks for Preview, the toolbar's native keyboard lifetime, + and Android's ordered back-button handling when closing Find. +- Adopt `installEditorHost` for native typing attributes and keyboard/toolbar + insets, and `revealEditorCaret` for focused-note scrolling. Both native shells + currently append CodeMirror configuration by watching `editorViewRef`; exposing + that reference would recreate the private boundary. Preserve their rAF plus + 150/400/800 ms retry cadence for staged keyboard geometry, cancelling retries on + teardown. Host overlay mount, removal, size, position, and viewport changes must + refresh the cached measurements and request a reveal together. In Android, + remove the old selection-clearance installer and its competing padding CSS in + the same migration. Keep its physical selection space distinct from additional + keyboard-toolbar scroll clearance. +- Adopt `getBrowseNotes` and `getAdjacentNotePath` from the public shell export for + both drawer note rows and swipe navigation. Preserve host-owned pins, natural + title sorting, stable ties, and the mobile recent-first fallback for none/manual. + Exclude database records at every depth beneath `.base`, including `pages/`; + Android currently includes them and iOS only checks the immediate directory. +- Adopt `getShellSnapshot`, `subscribeShell`, and `useShellSnapshot` for note/vault + metadata, selection, restoration, and history observations. These frozen copies + do not expose bodies, credentials, settings objects, pane layouts, or store + operations. Native note-index readiness remains separate from workspace + restoration. Keep native pins keyed by the host's stable vault token; the + exposed root can be an iOS friendly label rather than a durable identity. + Note mutations, task snapshots/actions, workspace commands, settings mutations, + and the command palette now use explicit public boundaries. Do not introduce a + generic selector over the store when adding future host operations. +- Adopt `getBrowseSnapshot`, `subscribeBrowse`, `useBrowseSnapshot`, and + `getBrowseDirectory` for the drawer's folder/database rows and enabled date + directory settings. Keep note and folder pins separate; database rows preserve + their title ordering. Pass database navigation targets to `openNote` without + interpreting their serialized paths. Adopt the three `request*Browse*` folder + actions together with host identity and vault-switch draining. Adopt + `createBrowseDatabase` and `requestRenameBrowseDatabase`: omitted creation target + uses configured placement, while an explicit target is primary-relative. Keep + legacy configured placement behavior even inside an active record directory; + explicit Browse actions reject database internals. Adopt `requestMoveNote` and + `requestRenameNote`, `requestArchiveNote`, `requestTrashNote`, `restoreNote`, and + `requestDeleteNotePermanently` with host identity and save draining. Finish batch + lifecycle coordination and native comment storage parity. +- In the task API slice, coordinate already-dispatched task mutation queues with + folder mutations and vault switches. This Browse slice invalidates task scans + and remaps the cached task index; it does not migrate the existing task-write + lifecycle or claim native host adoption. + +These remain local migration tasks. The package candidate deliberately does not +add wildcard exports to make the existing private imports compile. + +## 5. Contracts to share + +Use three distinct contracts. Combining them into a universal backend would obscure differences that matter. + +### A. Vault behavior and format + +Language-neutral fixtures describe inputs and expected results: Markdown bytes, frontmatter, task dates/status, tags, links, relative paths, system-folder mappings, database sidecars, rename results, and portable settings. + +Start with one useful slice: parse a note containing a dated task, edit that task, save, and read it back. Include timezone/day-boundary cases and exact content preservation. Expand fixtures in small behavior families. Record known intentional differences instead of making every client mimic every desktop feature. + +TypeScript and Go run the same cases where they implement the behavior. PHP runs the relevant persistence, payload, revision, and publication cases; it need not acquire a full editor parser. Preserve legacy `attachements` handling and custom system-folder mappings. + +For TypeScript clients, share the actual pure functions once fixtures prove equivalent behavior. For Go server and TUI, first share fixtures. Extract a small Go format module only if proven common functions justify its maintenance. Keep OS filesystem, HTTP, and terminal UI out of it. A module can initially live in a main-repository subdirectory and use proper path-prefixed Go release tags. [Go module source management](https://go.dev/doc/modules/managing-source) + +### B. Host bridge + +The editor asks for operations such as read note, save note, watch vault, open external URL, or show a native dialog. Each host implements what it supports and advertises capabilities for the rest. + +Specify error categories, cancellation, content encoding, and save preconditions alongside method signatures. Unsupported behavior must not look like a successful no-op. Keep runtime validation at IPC and HTTP boundaries; TypeScript types alone do not validate outside input. + +### C. Network protocols + +- **Self-hosted API:** Go filesystem service and its authentication/session model; consumed by the web adapter and TUI remote adapter. +- **Cloud API:** Laravel ownership, revisions, quotas, devices, idempotency, sharing, and billing entitlements; consumed by sync clients and a future browser adapter. + +Give each protocol a documented capability/version contract and request/response fixtures. Share identifiers, error concepts, and applicable semantics, but preserve backend-specific auth and persistence rules. If a machine-readable schema is introduced, pilot it on one endpoint before committing to broad code generation. + +The standalone TUI CLI/MCP and desktop-bundled CLI/MCP also need an explicit compatibility inventory. Keep existing `zn` installation and command resolution working. The [desktop CLI migration assessment](desktop-cli-tui-migration.md) recommends consuming the standalone Go binary, with compatibility checks and managed installation migration before replacement. + +## 6. Packages, artifacts, CI, and release ownership + +### Shared TypeScript packages + +Produce installable ESM packages with declarations, explicit exports, required styles/assets, and preserved dynamic imports. Validate them with `npm pack` in an isolated consumer that cannot see the workspace. Compiled packages are a suitable boundary for external consumers; merely pointing an export at workspace source does not prove portable packaging. [Turborepo package guidance](https://turborepo.dev/docs/core-concepts/internal-packages) + +Use one shared-core release version for the participating TypeScript packages initially. Keep desktop, mobile, server, and Cloud product releases independent. Consumers pin exact versions in lockfiles and update through reviewed dependency changes. A new desktop release does not require releasing both mobile apps. + +Prefer public scoped npm packages if the organization controls the scope. Check ownership and publishing credentials before choosing it. Immutable package archives with checksums are a workable interim transport. CI must never resolve a mutable branch or silently fetch the newest source revision. + +Use immutable candidate archives to prove the two mobile migrations before enabling the permanent publishing workflow. Keep their references accessible to clean-checkout CI; a local `file:` link into the main workspace does not satisfy the package milestone. + +### Web and public viewer artifacts + +Release separate artifacts for the self-hosted web app, Cloud web app when implemented, and public share viewer. The browser builds share source but select their adapter/bootstrap explicitly. Keep deployment base paths configurable and test non-root paths. + +Each artifact manifest records its version, source commit, SHA-256, entrypoints, asset list, and supported protocol/payload range. Go and Laravel pin an artifact manifest in their own release change. Fetching verifies the digest and archive paths, fails closed, and is covered by the consuming repository's CI. Do not fetch `latest` during production deployment. + +Retain the previous artifact and hashed assets during rollout so already-open pages can load lazy chunks. Laravel's deployed commit must serve the same pinned artifacts that passed CI. The public viewer and authenticated editor can roll back independently. Include copied viewer dependencies in the source build's vulnerability and license checks. + +### Compatibility policy + +- Keep external API changes additive during the migration. Old clients must not break merely because repositories moved. +- Capture the currently shipped desktop, mobile, web, and TUI versions as the initial compatibility baseline. Current plus previous integration tests are useful minimum coverage, not automatic permission to drop older installed mobile clients. +- Define support windows from actual shipped clients before any breaking API removal. Native app review and user upgrade delays make coordinated mandatory releases unreliable. +- Deprecations require usage evidence where available, a replacement, a documented removal version, and an explicit decision. No vault format changes belong in this cleanup. + +### Build graph + +Split CI into core, desktop, web, server, and artifact-consumer checks. A shared contract change fans out to its consumers. A desktop-window-only change does not require Go unit tests. A full release still runs the relevant integration matrix. + +Remove unconditional desktop preparation from web-only/package-only setup once its replacement is proven. Go unit/API tests use an explicit static fixture or injected asset filesystem and run with Go alone. Server release tests separately exercise the complete pinned web artifact. + +Keep a convenient local `dev:web-stack` command. It can use a configured server checkout or installed binary, while CI uses declared dependencies. Local convenience must not become an implicit release dependency. + +## 7. Ordered implementation plan + +Each row is a focused reviewable slice. Rows marked **repeat per family/consumer/channel** are templates for separate PRs, not permission to combine all instances. Keep each slice near two to five implementation files; split further if investigation expands its scope. Existing public entrypoints remain available until their replacements pass consumer checks. + +### Phase 0: Establish the baseline and resolve uncertainty + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P0.1 | Record runtime/release ownership and supported client baseline. Main architecture docs and release manifests. | None | Every binary, browser artifact, API consumer, CLI, and distribution channel has an owner and current reference. Mark superseded self-hosted-only assumptions in architecture docs. | +| P0.2 | Recover public viewer provenance in an isolated worktree. Historical `apps/share-viewer`, Laravel `ShareViewer.php`, share payload fixture. | None | Identify whether historical source reproduces the deployed contract. Build and render a representative share fixture. If not equivalent, document the gap and preserve the deployed bundle until resolved. | +| P0.3 | Define the first behavior fixture and format. Proposed fixture directory plus one existing domain test. | P0.1 | A dated-task read/edit/write case passes in the current TypeScript implementation; fixture captures bytes, dates, expected result, and any known divergence. | + +**Gate 0:** Agreed ownership, reproducible baseline checks, and a clear viewer recovery path. No repository creation is needed to reach this point. + +### Phase 1: Make shared boundaries one-way + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P1.1 | Remove one contracts/domain type cycle at a time. `bridge-contract/src/bridge.ts`, relevant domain type module, contract module. **Repeat per type family.** | P0.1 | Types have one owner; old imports still work through re-exports; affected workspace typechecks pass. | +| P1.2 | Remove host-specific public type assumptions. Bridge types and platform capability producers. **Repeat per host.** | P1.1 families complete | Contracts build without Node/Electron type dependencies; host identity and capability behavior remain accurate. | +| P1.3 | Move pure desktop helper imports. Wikilink rename first, then demo data in a separate slice. | P0.3, relevant P1.1 | Shared exports reproduce existing results; mobile imports no longer reach desktop main for that helper. Existing rename cases pass. | +| P1.4 | Add a stable mobile navigation/editor action surface. app-core exports, one mobile navigation call site. **Repeat per operation family.** | P1.1 | One complete user flow uses public actions/selectors with no private store mutation. Verify note open/edit/back navigation, then repeat for remaining flows. | +| P1.5 | Add import-boundary enforcement for completed migrations. Package exports and lint/check tooling. | Relevant P1 slices | CI rejects reintroduced reverse dependencies, desktop source imports from mobile, and undeclared migrated subpaths. Remaining temporary exceptions are listed. | + +**Gate 1:** Contracts no longer depend on domain implementations; pure helper imports have a valid home; host APIs exist for mobile integration. Run affected unit checks and actual desktop/web/mobile note flows before removing compatibility re-exports. + +### Phase 2: Ship packages and remove mobile source clones + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P2.1 | Package contracts, then domain. Package manifest/build config and isolated consumer fixture. **Separate slice per package.** | P1.1, P1.2 | `npm pack` output installs and typechecks outside the workspace; declared imports resolve; no sibling checkout is needed. | +| P2.2 | Package app-core and its assets. Export manifest, build config, isolated browser harness. | P2.1, public hooks from P1.4 | Packed editor loads, edits, and saves through a test host. CSS/fonts/wasm/lazy features load; React/CodeMirror state is not duplicated. | +| P2.3 | Migrate Android's dependency transport. `package.json`, lockfile, Vite/TS config, preparation script. | P1.4 complete for Android, P2.2 | Clean checkout installs pinned packages with `.zennotes-source` absent; no private aliases remain. Device/emulator test covers preference bootstrap, SAF note read/write, background/restore, and existing sync flow. | +| P2.4 | Migrate iOS's dependency transport using the proven package. Equivalent iOS build files. | P1.4 complete for iOS, P2.3 | Clean checkout works without source clone; native test covers note read/write, iCloud integration, lifecycle, and existing sync flow. Preserve unrelated Xcode changes. | +| P2.5 | Automate immutable shared-package releases and one consumer update. Main release workflow and consumer manifest. | P2.3, P2.4 | A release is reproducible from its commit; consumer upgrade and downgrade both pass. Mobile product version remains independent of core version. | + +**Gate 2:** Both mobile apps build and run from versioned packages. Neither clones the main repository nor imports its private source. Revert consumer dependency changes to the previous known-good pin if packaging fails; retain the old source preparation path only until the package rollout is verified. + +### Phase 3: Establish behavior and artifact compatibility + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P3.1 | Run the initial task fixture in Go server, then TUI. Existing parser tests and fixture loader. **Separate slice per consumer.** | P0.3 | Both report equivalent intended results or explicitly recorded product differences. Expand later by individual behavior family. | +| P3.2 | Publish the existing self-hosted HTTP contract fixtures. One note read/write endpoint family plus errors. | P0.1 | Current web bridge and TUI remote client pass against Go. Authentication, invalid paths, and stale/missing resources remain correctly handled. | +| P3.3 | Separate Go unit/API tests from production web embedding. `run-go-server-test.mjs`, HTTP asset dependency, fixture files. | P0.1 | A Go-only environment runs `go test ./...` using deliberate test assets. Full release tests still verify the real embedded app. | +| P3.4 | Release a self-hosted web artifact. Web build config, artifact manifest/generator, release job. | P3.2 | Immutable archive includes all assets and provenance. Browser smoke test covers login, vault list, note edit/save, reload, lazy feature, and non-root base path. | +| P3.5 | Consume the pinned web artifact from the existing Go server. Build helper, embed preparation, Docker build. | P3.3, P3.4 | Clean Go release build needs no Node or source workspace. Digest mismatch fails; binary serves the tested UI and API. Existing deployment config still works. | +| P3.6 | Release the restored viewer independently. Viewer source/build and payload fixture. | P0.2 | Public shares render representative links/assets/math and fallback behavior. Dependency provenance and payload compatibility are recorded. | +| P3.7 | Pin and verify the viewer in Laravel. `ShareViewer.php`, artifact manifest/fetch step, browser integration check. | P3.6 | Laravel CI exercises the actual viewer bundle, not just mocked availability. `/s` and `/p` keep working; fallback and independent rollback are verified. | + +**Gate 3:** Go tests are independent; Go release builds consume a verified web artifact; Laravel consumes a reproducible viewer artifact. All consumers use explicit pins. This phase can overlap mobile packaging where files and contracts are independent. + +### Phase 4: Extract Go after the boundary works + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P4.1 | Rehearse extraction in a scratch clone. Go module/import map, workflow ownership map, history filter recipe. | P3.5 | Extracted tree builds and tests without the original repository. Inventory old Go import consumers before any module-path change. Original history/tags remain untouched. | +| P4.2 | Prepare the new repository with the extracted source and CI. Destination `go.mod`, workflows, artifact pin. | P4.1; destination contents verified | Fresh clone produces the same functional binary and Docker behavior. Required permissions, tags, security policy, and release ownership are configured. | +| P4.3 | Move one distribution channel at a time. Docker workflow, Nix server definition, other server release metadata. **Separate slice per channel.** | P4.2 | Preserve image name/tags, binary name, config/env/volume behavior, and supported architectures. Rehearse candidate install/upgrade/rollback before switching the publisher. | +| P4.4 | Cut over main-repository entrypoints. Server workspace references, root scripts, `dev-web-stack`, docs. | P4.3 channels complete | Main CI no longer builds server source. Local web-stack development remains straightforward. Existing release links remain usable and point to the new owner where appropriate. | +| P4.5 | Remove obsolete server source and temporary adapters. Old server tree and compatibility wrappers. | One verified destination release and rollback rehearsal | Exactly one active server source/release owner remains. Main desktop/web packaging still passes; no accidental removal of desktop Nix/AUR/Homebrew configuration. | + +**Gate 4:** The new repository has shipped a verified release with preserved installation behavior. Until then, keep the old server release path available. Do not maintain indefinitely writable copies in two repositories. + +Use a scratch clone for history filtering, following the documented subdirectory extraction process; do not rewrite the working repository or its historical releases. [GitHub repository extraction guidance](https://docs.github.com/en/get-started/using-git/splitting-a-subfolder-out-into-a-new-repository) + +### Phase 5: Cloud browser integration as a separate product stream + +This stream can begin after the relevant Phase 1 host interfaces and Phase 3 artifact conventions are ready. It does not depend on Phase 4. Start with synced vaults and online access; local-only notes do not become available merely through login. + +Prefer serving the Cloud editor under the existing Laravel origin, using its session cookies and CSRF protection. Choose the route after checking existing routes. A separate frontend origin would add cross-origin session configuration without being necessary for repository separation. + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P5.1 | Define browser authentication and read-only vault access. Laravel browser routes/middleware and a service-level test. | Host contract; Cloud account/revision baseline | Existing session login and CSRF model apply. Another user's vault, revoked access, and expired session are denied. Preserve existing device-token clients. | +| P5.2 | Add the Cloud read-only browser adapter. `apps/web` bootstrap/adapter and Cloud artifact deployment pin. | P5.1; artifact conventions | Login, select synced vault, read note, refresh, and logout work. Unsupported capabilities are hidden or explained. No backend source is copied into the frontend. | +| P5.3 | Define cache/account isolation. Service worker and account-scoped browser storage. | P5.2 | Only explicitly allowed static assets enter the shell cache. Logout/account switching cannot expose another account's note data; old API caching policy is not reused. | +| P5.4 | Add safe browser mutations. Laravel browser actor integration, existing sync services, one save endpoint family. | P5.1 | Ownership, quotas, revision preconditions, idempotency, and revocation checks remain authoritative. New routes preserve exact Markdown bytes and fit rate limits. | +| P5.5 | Add recoverable browser editing. Cloud adapter save flow and draft storage. | P5.3, P5.4 | Stale revision, offline save, reload, and expired login preserve recoverable edits. Partial browser caches cannot generate filesystem-style deletions. | +| P5.6 | Validate encryption support and production rollout. Capability behavior, representative Cloud fixture, deployment manifest. | P5.5 | Encrypted payloads are either supported through an explicitly designed unlock flow or clearly unsupported in the browser. Ship behind a controlled rollout with artifact rollback; do not misrender ciphertext or overwrite it. | + +Use Laravel's first-party session authentication for browser access rather than putting a long-lived personal access token into browser storage. Browser mutations still need a server-controlled actor compatible with existing write semantics. [Laravel Sanctum guidance](https://laravel.com/framework/docs/13.x/sanctum) + +**Cloud gate:** Read-only access can ship before editing. Editing requires verified conflict handling and draft recovery. Full offline sync is a later scope with its own storage/eviction and deletion model. + +## 8. First five implementation PRs + +Start with these focused changes in the existing repositories: + +1. **Baseline and ownership map:** P0.1. Record client/artifact versions and update stale architecture assumptions. +2. **Viewer provenance:** P0.2. Prove the build source or document the exact recovery gap before changing Laravel assets. +3. **First cross-client behavior fixture:** P0.3. Establish the dated-task roundtrip baseline without changing behavior. +4. **First contracts dependency fix:** one P1.1 type family. Demonstrate the migration pattern with compatibility re-exports. +5. **Pure wikilink rename boundary:** first P1.3 slice. Remove one real desktop-internal dependency from mobile. + +After these, continue the remaining contract families and public mobile hooks toward the package milestone. Do not create six simultaneous restructuring branches. Finish one verified dependency boundary before stacking dependent moves on top of it. + +## 9. Validation and rollback matrix + +| Boundary | Required proof | Rollback | +| --- | --- | --- | +| Core packages to native clients | Clean install without sibling source; editor assets/lazy modules; native startup, file operations, lifecycle and sync | Revert package pin; retain previous artifact and compatible API | +| Shared vault semantics | Same applicable fixture results across implementations; no unintended note byte changes | Revert individual rule change; no data migration introduced | +| Browser to self-hosted API | Current shipped baseline plus new artifact; note flows, auth, watcher updates, TUI remote calls | Pin previous web artifact/server binary | +| Viewer to Laravel | Actual bundle renders payloads; public share/publication routes and fallback work | Restore previous viewer pin; keep old assets | +| Go repository extraction | Fresh-clone build; Docker/Nix/install paths; release candidate upgrade and rollback | Use previous publisher/release until cutover is verified | +| Cloud browser saves | Ownership isolation; stale revision; revocation; quota; draft recovery; account switching | Disable new browser access/editing and restore previous artifact; preserve synced revisions | + +Run scoped checks after each slice, and the relevant cross-client integration gate before removing its compatibility layer. Boundary work should preserve startup and editing performance; compare representative packaged builds to the recorded baseline, especially lazy loading and native preference bootstrap. + +## 10. Proposed architecture decisions + +The maintainer authorized implementation on September 15, 2026. The Go destination is `ZenNotes/znserver`. + +### ADR 1: Keep desktop and web with shared editor source + +**Context:** They already share app-core and regularly change together. +**Decision:** Keep them in the main repository, with independent CI and release targets. +**Alternative:** Split desktop, web, and core into three repositories immediately. +**Tradeoff:** One repository retains atomic editor changes. It requires import/build enforcement, but avoids three coordinated PRs for routine shared UI work. Revisit if ownership, access, or release independence actually becomes a constraint. + +### ADR 2: Preserve independent native and TUI products + +**Context:** Native integrations and distribution are platform-specific; mobile currently depends on main-repository source internals. +**Decision:** Keep current repositories and replace source clones with versioned packages. Keep TUI's existing backend adapter and release ownership. +**Alternative:** Merge all clients into one repository. +**Tradeoff:** Dependency updates become explicit release work, but platform toolchains and releases remain isolated. Share domain functions where language permits and behavioral fixtures otherwise. + +### ADR 3: Extract Go only after independent build and artifact consumption + +**Context:** Go owns a distinct self-hosted runtime but currently embeds workspace-built web assets. +**Decision:** Establish the artifact boundary first, then create one public server repository. +**Alternative:** Move the directory first or leave build coupling permanent. +**Tradeoff:** One additional release pipeline and artifact compatibility policy are necessary. The extraction is justified by a separate deployable/runtime, and its risk is reduced by proving independence before moving source. + +### ADR 4: Keep Laravel Cloud and website private and together + +**Context:** Account, billing, entitlement, sync, and publication behavior share existing Laravel services and deployment. +**Decision:** Preserve this ownership and consume public frontend artifacts through pinned manifests. +**Alternative:** Merge with Go, copy the editor into Laravel, or split marketing/accounts/Cloud into services now. +**Tradeoff:** Artifact contracts require maintenance, but there is one authoritative Cloud ownership/revision model and no duplicate editor fork. No extra production services are introduced by this plan. + +### ADR 5: Share semantics without forcing one universal API + +**Context:** Clients share vault behavior while native filesystem, self-hosted HTTP, and Cloud revisions have different failure and security models. +**Decision:** Separate format fixtures, host interfaces, and named network protocols. Keep public editor and share-viewer artifacts independent. +**Alternative:** A single storage API hiding every difference, or unrestricted duplicated logic. +**Tradeoff:** Adapters remain explicit and some cross-language implementations remain separate. Tests define the shared behavior, while capabilities expose meaningful differences. + +## 11. Decisions to settle at the relevant gate + +- The maintainer selected `ZenNotes/znserver` on September 15, 2026. Inspect its existing contents before extraction. +- Verify npm scope ownership and package publication permissions before P2.5. +- Establish the actual supported installed-client baseline before protocol deprecation; do not infer it only from repository HEADs. +- Resolve viewer source provenance before replacing the tracked Laravel bundle. +- Decide whether browser access to encrypted vaults is in the first Cloud release after confirming the intended encryption/unlock model. +- Consider a shared pure Go format module only after fixture adoption reveals sufficient identical behavior to justify it. + +None of these prevents starting the baseline and internal dependency work. + +## 12. Definition of done + +- [x] Every client/backend has a documented owner, public interface, and release artifact. +- [x] Contracts and domain packages have one-way dependencies; local checks pass and CI gates are prepared. +- [x] iOS and Android build from declared immutable package candidates with no source checkout or desktop-internal imports; clean publication remains gated. +- [x] Core package candidates are tested in real external consumers before release. +- [x] Applicable vault semantics and self-hosted network behavior have shared fixtures across implementations. +- [x] Go unit/API tests need only Go; candidate server builds use a pinned, verified web artifact. +- [ ] Laravel serves a reproducibly built, pinned viewer through its tested deployment commit; the Cloud editor follows the same rule when introduced. +- [ ] Go source and release ownership move once, with compatibility checks and rollback evidence. +- [ ] Existing vaults, share URLs, installers/updaters, CLI commands, Docker configuration, and package channels remain usable. +- [ ] Compatibility shims and stale architecture documentation are removed or explicitly tracked. + +The boundary cleanup is complete when those conditions hold. Cloud web access has its own read-only and editing gates and is not a prerequisite for the Go split. + +## Source anchors + +- [Main workspace configuration](/Users/adibhanna/Developer/opensource/zennotes/package.json), [bridge types](/Users/adibhanna/Developer/opensource/zennotes/packages/bridge-contract/src/bridge.ts), [app-core exports](/Users/adibhanna/Developer/opensource/zennotes/packages/app-core/package.json). +- [Existing architecture](/Users/adibhanna/Developer/opensource/zennotes/docs/monorepo-architecture.md), [web design](/Users/adibhanna/Developer/opensource/zennotes/docs/web-architecture.md), [web bridge](/Users/adibhanna/Developer/opensource/zennotes/apps/web/src/bridge/http-bridge.ts), [service worker](/Users/adibhanna/Developer/opensource/zennotes/apps/web/public/sw.js). +- [Android source preparation](/Users/adibhanna/Developer/apps/zennotesandroid/tooling/prepare-zennotes.sh), [Android startup](/Users/adibhanna/Developer/apps/zennotesandroid/src/bootstrap.ts), [iOS source preparation](/Users/adibhanna/Developer/apps/zennotesiphone/tooling/prepare-zennotes.sh). +- [Go test wrapper](/Users/adibhanna/Developer/opensource/zennotes/tooling/scripts/run-go-server-test.mjs), [Go asset embedding](/Users/adibhanna/Developer/opensource/zennotes/apps/server/web/embed.go), [TUI backend interface](/Users/adibhanna/Developer/opensource/zennotescli/internal/backend/backend.go), [TUI vault types](/Users/adibhanna/Developer/opensource/zennotescli/internal/vault/types.go). +- [Laravel viewer loader](/Users/adibhanna/Developer/Laravel/zennotes/app/Services/ShareViewer.php), [Cloud write service](/Users/adibhanna/Developer/Laravel/zennotes/app/Services/VaultSyncService.php), [device middleware](/Users/adibhanna/Developer/Laravel/zennotes/app/Http/Middleware/EnsureActiveDevice.php), [deployment script](/Users/adibhanna/Developer/Laravel/zennotes/.github/deploy-production.mjs). diff --git a/docs/specs/mobile/README.md b/docs/specs/mobile/README.md index 6b5fcba1..5eb5f044 100644 --- a/docs/specs/mobile/README.md +++ b/docs/specs/mobile/README.md @@ -34,7 +34,7 @@ ZenNotes already runs three product modes over one product core (`packages/app-c ```text apps/desktop → Electron shell + Electron/IPC bridge (runtime: 'desktop') apps/web → Vite/PWA shell + HTTP bridge → Go server (runtime: 'web') -apps/server → Go backend +ZenNotes/znserver (separate repository) → Go backend apps/mobile → Capacitor shell + native bridge (runtime: 'mobile') ← NEW packages/app-core → shared React UI + renderers (reused verbatim) packages/bridge-contract → the ZenBridge seam (extended with a 'mobile' runtime + capability flags) diff --git a/docs/v2.50.4-boundary-integration.md b/docs/v2.50.4-boundary-integration.md new file mode 100644 index 00000000..06c99c94 --- /dev/null +++ b/docs/v2.50.4-boundary-integration.md @@ -0,0 +1,80 @@ +# v2.50.4 integration into the boundary migration + +Verified locally on September 15, 2026 (America/Chicago). + +## Released source + +- Release: [v2.50.4](https://github.com/ZenNotes/zennotes/releases/tag/v2.50.4). +- Commit: `850cf5f8a7f7e10d3f47df1c5732209d8e0c2dcc`. +- Annotated tag object: `c82ec31a9be663d8fa3827c865c28f8bb5020bd7`. +- [Release build 35036589163](https://github.com/ZenNotes/zennotes/actions/runs/35036589163) + completed successfully across all platform jobs; 29 release assets were present. +- All 38 changed paths since the migration baseline were reconciled with the local + migration. The sole textual conflict was workspace metadata in `package-lock.json`; + both the v2.50.4 version and the migration's TypeScript dependency were retained. + +The included fixes suppress the wikilink picker inside code (#783), render live +modified-date tokens (#784), preserve note links when assets move or are renamed +(#785), and exclude forwarded/cancelled tasks from Kanban (#786). Desktop and Go +asset-rewrite implementations and their tests are included. + +The new asset actions also use the migration's workspace reservation. They drain +saves, prevent concurrent typing or vault switches during disk rewrites, refresh +open note bodies, and propagate failures while releasing the reservation. Four +additional tests cover rename/move, pending saves, save failure, and host failure. + +## Current local candidates + +| Consumer boundary | Candidate | +| --- | --- | +| Core, installed by both mobile apps | `2.50.4-core.h8a09555824b619b5` | +| Contracts and domain | `2.50.4-boundaries.h776a0eb6d6ed1862` | +| Self-hosted web | `2.50.4-web.hffc8c055ae2667f2` | +| Public viewer, selected by Laravel | `2.50.4-viewer.h475ea70770234498` | + +These are immutable local candidates with dirty-source provenance. Their source +commit field correctly names the unchanged local HEAD; it is not a claim that the +migration has been committed. Hashes in the archived manifests identify the actual +bytes. Native product versions remain independent of the shared package version. + +## Validation after integration + +- Eight workspace typechecks; 4,806 passing source tests and five existing skips. +- Desktop production build, including CLI execution without `node_modules`. +- Standalone nested package installs/builds and 50 browser checks each with Vite 6 + and Vite 8, using the exact new core candidate. +- Android: 138 tests, clean package install, typecheck, production/fixture builds, + native unit tests/lint/build, four instrumentation tests, 20 runtime checks and + three cold-start checks on the disposable emulator. +- iOS: 102 tests, clean package install, typecheck, production/fixture builds, + native simulator build, 20 runtime checks and three cold-start checks. +- Laravel: 17 focused tests (141 assertions), 11 importer tests, and six browser + cases using actual Laravel markup and the rebuilt viewer. The earlier full + Laravel run passed 793 tests (6,272 assertions); application PHP did not change + during this integration. +- Go-only extracted module: vet, full tests, API binary, verified web import, + embedded tests/build. TUI full tests/vet/build and real-server HTTP contracts pass. +- Docker arm64 and amd64 builds plus authenticated root/prefixed runtime checks. + Released v2.50.4 -> integrated candidate -> released v2.50.4 preserves exact + fixture bytes and continued writes at both mounts. +- Nix aarch64-linux build and authenticated exact Unicode read/write pass. +- Five artifact packer tests, shared fixture checksum check, and production + dependency audit (zero advisories). + +Detailed manifests, reports, and logs are in the ignored local +[validation evidence](../dist/ecosystem-boundary-validation/v2.50.4/). + +## Preserved state and next gates + +All five repositories retain their original HEAD and byte-identical Git index. +The main index still contains its original 23 files, 570 insertions and 487 +deletions. The unrelated iOS Xcode project edits are byte-identical to the backup. +Complete backups precede reconciliation; the backup location and incoming-file +plan are retained in the local evidence. + +No commits, pushes, publication, deployment, or repository-history rewrites were +performed. The existing Go source remains in main until an approved destination +release passes cutover checks. Clean publication, account-backed Cloud/iCloud +staging, remote CI/macOS Nix, and destination import/channel cutover remain in +[the cutover runbook](boundary-release-cutover.md). Cloud browser login/editing +remains a separate feature. diff --git a/docs/web-architecture.md b/docs/web-architecture.md index 6a373b64..eacc1606 100644 --- a/docs/web-architecture.md +++ b/docs/web-architecture.md @@ -1,5 +1,21 @@ # ZenNotes Web Architecture +> Historical self-hosted design. The Go-backed web client is implemented, while +> Laravel in the separate private `ZenNotes/website` repository now owns Cloud. +> The hosted-SaaS exclusions and proposed hosted-Go deployment below describe the +> original design, not the current ecosystem direction. Follow the +> [ecosystem boundaries plan](specs/ecosystem-boundaries-and-repository-plan.md) +> for the active migration and future Cloud browser adapter. + +## Current boundary implementation + +The maintained browser shell still uses the Go adapter. `artifact:web` emits a +verified immutable archive for Go-only server builds; `apps/share-viewer` emits a +separate read-only public artifact for Laravel. Mobile shells now consume exact +shared packages through public APIs. None of these adapters supplies authenticated +Laravel Cloud browser editing yet. Local build/runtime evidence and approval-gated +publication/cutover steps are in the ecosystem plan linked above. + Target: turn ZenNotes into a progressive web app (PWA) that can also be self-hosted on a home server and driven entirely from a browser, without losing what makes ZenNotes ZenNotes — keyboard-first editing, vim diff --git a/flake.nix b/flake.nix index 83a3b224..aa4a8d07 100644 --- a/flake.nix +++ b/flake.nix @@ -12,26 +12,23 @@ forAllSystems = nixpkgs.lib.genAttrs systems; in { + # The desktop package wraps the prebuilt linux-x64 release tarball, so it + # only exists on x86_64-linux. The self-hosted server is packaged in its + # own repository, ZenNotes/znserver. packages = forAllSystems (system: let pkgs = nixpkgs.legacyPackages.${system}; - zennotes-server = pkgs.callPackage ./packaging/nix/package-server.nix { }; in - { inherit zennotes-server; } - # The desktop package wraps the prebuilt linux-x64 release tarball, so it - # only exists on x86_64-linux; elsewhere the server is the default. - // ( - if system == "x86_64-linux" then - let - zennotes-desktop = pkgs.callPackage ./packaging/nix/package-desktop.nix { }; - in - { - inherit zennotes-desktop; - default = zennotes-desktop; - } - else - { default = zennotes-server; } - ) + if system == "x86_64-linux" then + let + zennotes-desktop = pkgs.callPackage ./packaging/nix/package-desktop.nix { }; + in + { + inherit zennotes-desktop; + default = zennotes-desktop; + } + else + { } ); devShell = forAllSystems (system: @@ -40,7 +37,6 @@ in pkgs.mkShell { buildInputs = with pkgs; [ - go nodejs electron turbo diff --git a/guide.md b/guide.md index 53f235b8..c6dec50d 100644 --- a/guide.md +++ b/guide.md @@ -165,7 +165,7 @@ Use this if you do not want Docker and you are okay running both the frontend an - Node.js 22+ - npm -- Go 1.22+ +- Go, only if you want to run the server from a ZenNotes/znserver checkout instead of the pinned release binary ### Steps @@ -175,7 +175,8 @@ Install dependencies: npm ci ``` -Run both the web client and Go server together: +Run both the web client and the pinned Go server release together (the server +binary is downloaded from ZenNotes/znserver and checksum-verified on first use): ```bash make web-stack @@ -208,20 +209,25 @@ That means: ## 4. Run the self-hosted server without Docker -If you want a built server binary instead of dev mode: +The server is a single static binary released by +[ZenNotes/znserver](https://github.com/ZenNotes/znserver). Download the asset +for your platform from that repository's releases page, check it against the +release's `SHA256SUMS`, and run it: ```bash -npm ci -make server-build -./apps/server/bin/zennotes-server +chmod +x zennotes-server-linux-amd64 +./zennotes-server-linux-amd64 ``` +From a checkout of this repository, `npm run server:binary` downloads the +pinned release, verifies its checksum, and prints the path of the binary. + Then open: - [http://localhost:7878](http://localhost:7878) - or `http://YOUR_SERVER_IP:7878` -The built server embeds the web app, so you do not need to run `dev:web` for this path. +The released server embeds the web app, so you do not need to run `dev:web` for this path. ## 5. Choose a vault in the web version diff --git a/package-lock.json b/package-lock.json index 5f571790..f5a5a26f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.50.4", + "version": "2.51.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.50.4", + "version": "2.51.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.50.4", + "version": "2.51.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -872,13 +872,80 @@ "node": ">=18" } }, - "apps/server": { - "name": "@zennotes/server", - "version": "2.50.4" + "apps/share-viewer": { + "name": "@zennotes/share-viewer", + "version": "2.51.0", + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-vim": "^6.3.0", + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", + "codemirror": "^6.0.1", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "gray-matter": "^4.0.3", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.4.3" + } + }, + "apps/share-viewer/node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "apps/share-viewer/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "apps/web": { "name": "@zennotes/web", - "version": "2.50.4", + "version": "2.51.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -6083,8 +6150,8 @@ "resolved": "apps/desktop", "link": true }, - "node_modules/@zennotes/server": { - "resolved": "apps/server", + "node_modules/@zennotes/share-viewer": { + "resolved": "apps/share-viewer", "link": true }, "node_modules/@zennotes/shared-domain": { @@ -11115,6 +11182,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-phrasing": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", @@ -13593,6 +13674,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-frontmatter": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", @@ -16286,7 +16382,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.50.4", + "version": "2.51.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16311,12 +16407,15 @@ "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@excalidraw/excalidraw": "^0.18.1", + "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.1", "@myriaddreamin/typst-ts-renderer": "^0.7.0", "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.11.2", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -16346,6 +16445,13 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vfile": "^6.0.3", "vite": "^6.4.3", "vitest": "^3.2.6" } @@ -16363,22 +16469,26 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.50.4" + "version": "2.51.0", + "devDependencies": { + "typescript": "^5.7.2" + } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.50.4", + "version": "2.51.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" }, "devDependencies": { + "typescript": "^5.7.2", "vitest": "^3.2.6" } }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.50.4" + "version": "2.51.0" } } } diff --git a/package.json b/package.json index 9acf805b..5a9c416f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.50.4", + "version": "2.51.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { @@ -26,13 +26,19 @@ "dev": "npm run dev:desktop", "dev:desktop": "npm run dev --workspace @zennotes/desktop", "dev:web": "npm run dev --workspace @zennotes/web", - "dev:server": "npm run dev --workspace @zennotes/server", + "dev:server": "node tooling/scripts/run-server-dev.mjs", "dev:web-stack": "node tooling/scripts/dev-web-stack.mjs", "start": "npm run start --workspace @zennotes/desktop", "typecheck": "turbo run typecheck", "test": "turbo run test", "test:run": "turbo run test:run", - "build": "turbo run build --filter=!@zennotes/server && npm run build --workspace @zennotes/server", + "test:shared-packages": "node tooling/scripts/test-shared-packages.mjs", + "test:web-dist-lock": "node --test tooling/scripts/web-dist-lock.test.mjs", + "artifact:web": "node tooling/scripts/pack-web-artifact.mjs", + "test:web-artifact": "node --test tooling/scripts/pack-web-artifact.test.mjs", + "check:contract-fixtures": "node tooling/scripts/sync-contract-fixtures.mjs", + "sync:contract-fixtures": "node tooling/scripts/sync-contract-fixtures.mjs --write", + "build": "turbo run build", "build:prod": "npm run typecheck && npm run test:run && npm run build", "perf:bench": "node tooling/scripts/perf-large-vault.mjs", "perf:desktop-runtime": "node tooling/scripts/perf-desktop-runtime.mjs", @@ -46,7 +52,14 @@ "dist:mac": "npm run dist:mac --workspace @zennotes/desktop", "dist:win": "npm run dist:win --workspace @zennotes/desktop", "dist:linux": "npm run dist:linux --workspace @zennotes/desktop", - "perf:editor-scroll": "node tooling/scripts/perf-editor-scroll.mjs" + "perf:editor-scroll": "node tooling/scripts/perf-editor-scroll.mjs", + "artifact:app-core": "node tooling/scripts/pack-app-core.mjs", + "test:app-core-package": "node tooling/scripts/test-app-core-package.mjs", + "test:app-core-browser": "node tooling/scripts/test-app-core-browser.mjs", + "pack:share-viewer": "node tooling/scripts/pack-share-viewer.mjs", + "server:binary": "node tooling/scripts/server-binary.mjs", + "terminal:stage": "node tooling/scripts/terminal-artifact.mjs", + "test:terminal": "node --test tooling/scripts/terminal-artifact.test.mjs tooling/scripts/terminal-launcher.test.mjs" }, "devDependencies": { "patch-package": "8.0.1", diff --git a/packages/app-core/README.md b/packages/app-core/README.md new file mode 100644 index 00000000..f043a227 --- /dev/null +++ b/packages/app-core/README.md @@ -0,0 +1,535 @@ +# Shared application core + +This package owns the shared React application, editor, navigation state, and +feature orchestration. Hosts implement the bridge and own native I/O. Public +exports are the integration boundary; the internal store and pane tree remain +implementation details. + +## Public exports + +- `@zennotes/app-core/main`: application bootstrap and Cloud auto-sync request. +- `@zennotes/app-core/navigation`: note navigation and Home behavior for shells. +- `@zennotes/app-core/notes`: prompted note moves and renames with host-session and save guards. +- `@zennotes/app-core/shell`: immutable note metadata, shell observations, and mobile Browse ordering. +- `@zennotes/app-core/browse`: folder/database rows, date-directory settings, and confirmed folder actions for native drawers. +- `@zennotes/app-core/editor`: run formatting/search commands, inspect selection, + configure native typing/insets, and import attachments without accessing + CodeMirror or the store. +- `@zennotes/app-core/tasks`: immutable task observations, today's groups, refresh, navigation, and Kanban moves. +- `@zennotes/app-core/workspace`: restoration, profile metadata, workspace switching and save draining. +- `@zennotes/app-core/settings`: observed theme/editor settings and supported updates. +- `@zennotes/app-core/commands`: command descriptions, checked invocation, and core palettes. +- `@zennotes/app-core/dialogs`: host prompts and confirmations that do not replace pending dialogs. +- `@zennotes/app-core/host`: explicit host kind and advertised capabilities. +- `@zennotes/app-core/styles.css`: shared styles, also imported by `main`. +- `@zennotes/app-core/vite`: build integration for lazy WASM and drawing fonts. + +The navigation export provides: + +| API | Behavior | +| --- | --- | +| `openNote(path)` | Opens a vault-relative note or app-generated page path through normal saving and history. | +| `goBack()` / `goForward()` | Uses the existing note navigation history. | +| `goHome()` | Shows Home without closing tabs; starts normal saving for pending edits. | +| `useSelectedNotePath()` | React hook exposing the current path, or null. | +| `installHomeGuard()` | Keeps Home visible across background rescans; returns a cleanup function. | + +Shells that offer Home while retaining open tabs install the guard once during +bootstrap, before mounting React or registering other store subscribers. A mount +effect runs too late to protect those subscribers from a rescan transition: + +```tsx +// Install the host bridge and restore preferences first. +const { renderZenNotesApp } = await import('@zennotes/app-core/main') +const { installHomeGuard } = await import('@zennotes/app-core/navigation') +const disposeHomeGuard = installHomeGuard() +renderZenNotesApp(document.getElementById('root')!) +// Call disposeHomeGuard() when the host shell is torn down. +``` + +The iOS and Android shells now adopt these exports together with editor commands, +settings, workspace lifecycle, and attachment handling. Their installed package +checks reject the old private paths so only one application-state instance exists. + +## Note actions + +`requestMoveNote(host, path)` prompts for a logical `inbox` or `archive` destination, +including a subfolder such as `inbox/Projects`. The host resolves logical folders +through its vault settings. The prompt starts in the note's actual folder even +with custom folder names or primary notes at the vault root. Missing notes, +trashed notes, database record pages, hidden folders, traversal segments, and +database destinations are rejected. New ordinary subfolders may be created. + +The host supplies `isCurrent()` against a vault token captured before the prompt. +Results are `completed`, `cancelled`, `stale`, or `unavailable`; operational errors +reject. A dispatched operation may finish in its original vault after its token +becomes stale, so callers must not automatically retry it. Hosts must await the +normal save/drain step before replacing the active vault or bridge. + +Moves wait for note and comment saves, preserve edits made during the operation, +and reconcile tabs, comments, tasks, references, and manual order to the canonical +returned path. A move requested during a task write rejects so it can be retried +after that task settles; new task actions during the move show a wait message. +Desktop and Go roll back the note and comment file together on failure. If rollback +fails, the existing `FOLDER_STATE_UNCERTAIN` recovery guard retains buffers and +blocks writes until the vault is reloaded. + +`requestRenameNote(host, path)` prompts for a title and preserves the note's +existing directory and file type. The host's returned path/title is authoritative, +including collision suffixes. Renames hold note, comment, database, and task writes +across the vault while inbound wikilinks are updated. Open buffers, including edits +made during the rename, receive the same link rewrite before saving again. Existing +heading-sync preferences apply. Core note writers, including tag rewrites, task +rollover, record pages, imports, and templates, cannot overlap the operation. +Vault switching drains those writers as well as pending editor saves. A failed save +retains dirty buffers and rejects; +the rename may already have completed, so check the current snapshot before retrying. + +Host backlink rewriting remains best effort for closed notes. A successful rename +is not an atomic transaction covering every inbound file or another client's edits. +Desktop and Go roll back the renamed note and its comment sidecar together when +that relocation fails; failed rollback activates the recovery guard described above. + +`requestArchiveNote`, `requestTrashNote`, `restoreNote`, and +`requestDeleteNotePermanently` use the same host token and result contract. +Archive confirms when indexed unfinished tasks exist. Trash and permanent deletion +always confirm; permanent deletion is available only for trashed ordinary notes. +Restore accepts archive or trash and uses the host's configured primary notes +location. These public actions exclude database record pages. + +Archive and vault Trash save late edits at the returned path before closing the +editor. If that save fails, the destination stays open and dirty. Restore keeps +open tabs at the canonical returned path. Permanent deletion and temporary-session +system Trash lock every editor for the note after confirmation, save first, then +remove it. An active IME composition must finish before deletion. Failed saves or +host operations leave the note editable. The lock preserves existing Vim input and +read-only restrictions and covers the pinned reference editor and Preview writes. + +Desktop and Go detach content and comment sidecars together before permanent +cleanup. A cleanup failure is logged and may retain files in private quarantine; +this is not secure erasure. System Trash keeps the original note filename for file +manager restoration, rolls back comment detachment if the OS refuses, and removes +the old comment sidecar after success. Restoring that file through the OS does not +restore its discussion. Temporary sessions without comments gain no private folder. + +Single-note menus, file-task deletion, bulk Sidebar actions, Empty Trash, and +database row/page batches now use coordinated guards. Native storage adapters +implement matching note/comment rollback and have lifecycle fixture coverage. + +## Shell snapshots and Browse ordering + +`getShellSnapshot()` returns a frozen snapshot of the current vault metadata, +workspace mode/restoration, note index metadata, selected path/note, history +availability, and note sort preference. Each note contains only its path, title, +logical folder, folder-relative parent directory, and creation/update timestamps. +The snapshot does not expose note bodies, remote credentials, settings objects, +editor views, or store actions. Home and virtual pages have no selected note. + +`subscribeShell((next, previous) => ...)` observes public changes and returns a +disposer. It does not send an initial notification; use `getShellSnapshot()` for +the initial value. `useShellSnapshot()` provides the same data to React. Repeated +reads and unrelated editor changes retain snapshot identity; selection changes +reuse the frozen note index. An index refresh may produce a new snapshot even if +its metadata is equal. Previously returned values never change. + +`workspaceRestored` describes app-core's restoration step. Native note-index +readiness and keyboard/lifecycle state still belong to the host. Vault roots are +display/change metadata, not durable persistence keys or authorization for I/O. +In particular, iOS can expose a friendly remote-vault label there. Continue using +the host's stable vault token when storing pins or other native preferences. + +```ts +import { getShellSnapshot, getBrowseNotes, getAdjacentNotePath } from '@zennotes/app-core/shell' +import { openNote } from '@zennotes/app-core/navigation' + +const current = getShellSnapshot() +const rows = getBrowseNotes(current, 'Projects', pinnedPaths) +const next = current.selectedPath && + getAdjacentNotePath(current, current.selectedPath, 'next', pinnedPaths) +if (next) await openNote(next) +``` + +Browse helpers share the mobile drawer's ordering: pinned notes first, with the +chosen sort preserved within each group. `none` and `manual` retain the mobile +fallback to most recently edited; desktop manual ordering is unchanged. Names +use natural sorting, so Note 2 precedes Note 10. Ties retain note-index order. +Pins remain host-owned and must belong to the snapshot's vault. + +`getBrowseNotes` takes a directory relative to the primary notes area, with an +empty string for its root. Custom system-folder mappings and notes stored at the +vault root are resolved by app-core. Only immediate primary-folder notes appear; +database records below any `.base` ancestor are excluded. Adjacent navigation uses +that same list, does not wrap, and returns null for missing/virtual paths or notes +outside the primary area. Both helpers only query the supplied snapshot. Read a +fresh snapshot when handling an action, then use the normal navigation API. + +Database/note batches, tasks, settings, dialogs, commands, and workspace lifecycle +now have named public APIs. Both native shells use those exports; boundary checks +reject private imports and source-checkout aliases. + +## Browse folders and databases + +`getBrowseSnapshot()`, `subscribeBrowse()`, and `useBrowseSnapshot()` provide the +drawer's data without subscribing to editor selection or cursor changes. This +snapshot reuses the shell's frozen note metadata and adds frozen primary-folder +and database rows, the note sort order, vault display/change metadata, and enabled +daily/weekly/monthly directory settings. Disabled date directories are null. +Directory settings remain unchanged, including any patterns; this API does not +expand date patterns or check whether their folders exist. + +```tsx +import { getBrowseDirectory, useBrowseSnapshot } from '@zennotes/app-core/browse' +import { openNote } from '@zennotes/app-core/navigation' + +const snapshot = useBrowseSnapshot() +const rows = getBrowseDirectory(snapshot, directory, { + notes: pinnedNotePaths, + folders: pinnedFolderDirectories +}) +// A folder row's directory becomes the next local drawer location. +// Note and database row paths are navigation targets: +await openNote(rows.databases[0].path) +``` + +The directory argument and folder pins are relative to the primary notes area. +The empty string means its root. Results contain separate `folders`, `databases`, +and `notes` arrays. Folders sort by title with pinned folders first; databases +sort by title without pin partitioning; notes use the shared Browse ordering. +Empty folders remain visible. Database internals under `.base` never become +ordinary drawer rows, including nested `pages/` directories. + +Database paths are opaque app-generated navigation targets. Pass them to +`openNote`; do not construct their URLs or treat them as filesystem paths. +App-core handles custom system-folder mappings and notes stored at the vault +root. The snapshot exposes no mutable `FolderEntry` or `VaultSettings` objects. + +Subscriptions behave like `subscribeShell`: no initial notification, only public +changes, coherent previous/next snapshots, and a returned disposer. Unchanged +folder and date data retain identity when notes change. Pins remain host-owned, +keyed by the native host's stable vault token. + +### Folder and database actions + +- `createBrowseDatabase(host, directory?)` creates and opens an untitled database. Omitting the directory uses the configured database location, including the active note's folder. Explicit `''` selects the primary root; any other explicit directory must be an existing ordinary Browse folder. +- `requestRenameBrowseDatabase(host, directory)` prompts for a database title and preserves host collision numbering. Names starting with a dot are rejected because vault scanners hide those directories. Case-only renames retain existing host behavior and can receive a numbered suffix on case-insensitive filesystems. +- `requestCreateBrowseFolder(host, directory = '')` prompts for a child folder. +- `requestRenameBrowseFolder(host, directory)` prompts for an ordinary folder's leaf name. +- `requestDeleteBrowseDirectory(host, directory)` confirms permanent deletion of an ordinary folder or an entire database, with the appropriate warning. + +All directories are relative to the primary notes area. Root deletion, missing +rows, database internals, invalid names, and overlapping dialogs are rejected. +The actions return `completed`, `cancelled`, `stale`, or `unavailable`; host I/O +errors reject the promise and the caller must show the error. `stale` means the +context changed and further work stopped. An already dispatched operation may +have finished in the original vault, so do not automatically retry it. + +`host.isCurrent()` must compare a token captured before opening the dialog with +the native host's current vault/session token. Invalidate that token synchronously +when a switch or teardown begins. Then drain current folder operations and pending +saves before changing the bridge's active vault. An operation already dispatched +must finish reconciling paths and persisting favorites in its original vault. +Renderer vault labels alone are insufficient. The public workspace transition +owns this drain, and hosts compare the captured workspace generation as well as +their native session token. Cancelled or failed switches do not revive old captures. + +Folder operations coordinate pending note/database/comment saves, move open tabs, +manual order, references, and cached metadata to the host's canonical returned +path, and discard stale reads. Desktop and Go also move the parallel comment +subtree; a pre-existing destination comment subtree causes a rename to fail +before changing content. Delete quarantines content and comments together before +cleanup. Temporary desktop sessions with no comments delete directly without +creating ZenNotes metadata. Native menus, dismissal, and pins remain host-owned. + +If a host reports `FOLDER_STATE_UNCERTAIN:` after a failed rollback, app-core keeps +buffers in memory and blocks further writes to that subtree. A vault switch's save +step also rejects. The host must show the error and recover/reload the vault before +continuing; do not automatically retry a partially completed filesystem operation. + +Native `MobileVault` operations now implement equivalent comment-subtree rollback. +The host must keep its active vault fixed until database creation also finishes, +since HTTP creation spans multiple file operations. +Database renames use the same save and workspace reconciliation as folder renames. +Note lifecycle actions use the notes export described above. Future adapters must provide equivalent file/comment rollback before adoption. + +## Batch lifecycle and native shell actions + +`requestNoteBatch(host, paths, action)` confirms the selection once and applies +moves sequentially. The result distinguishes completed source paths from +unconfirmed paths. `NoteBatchError` retains both lists after an operational failure; +an unconfirmed item may already have moved before its final save failed. Refresh +and inspect the workspace before retrying. Previously completed moves stay complete. + +`requestEmptyTrash(host)` saves and freezes the entire configured Trash subtree, +including database grids, before deleting its contents and comments. Cancellation +or failure releases editors without removing their buffers. Desktop and Go use +transactional relocation before cleanup; remapped Trash paths are supported. + +Database row deletion materializes each exclusively owned linked page's latest +properties and body before committing the rows/schema. A save failure retains +recoverable rows. After the database commit, pages move sequentially through the +same note guard. A later move failure leaves remaining pages saved standalone and +reports partial completion. Shared or foreign page mappings are detached without +changing those files. The whole operation is drained before a vault switch. + +Task snapshots are frozen copies. `moveTaskToColumn` takes the host's captured +vault identity and expected grouping, then uses desktop's existing queued writer; +its boolean reports whether the request was recognized, while write failures use +core's existing toast UI. `getTodayTasks` applies the same display filtering and +file order as core. Hosts retain widget limits, theme sampling, and native updates. + +Workspace snapshots expose profile display metadata, never credentials or store +methods. Native vault tokens pass unchanged to the bridge. `flushWorkspace` +waits for pending file, row, task, database, and editor saves; unsaved buffers reject +instead of allowing a vault switch to discard them. Presentation options control +panel visibility without exposing the pane tree. `readPersistedHomeState` owns +interpretation of the persisted layout for mobile cold-start landing. + +`getAppCommands` returns descriptions; `runAppCommand` resolves availability again +at invocation. Editor presentation exposes the active mode and note availability, +without exposing CodeMirror. Navigation also accepts an initial note mode and +follows wikilinks without taking editor focus. Tag-presence observation includes +live note tags and excludes Typst preambles. + +Hosts may supply `ZenAppInfo.hostKind` as `desktop`, `browser`, `ios`, or `android`. +The legacy renderer `runtime` remains compatible with installed bridges. Use +capabilities for feature availability, not the reported OS or renderer family. + +## Native editor host integration + +Install host configuration after restoring preferences and before mounting React. +Typing attributes then exist before any editor receives its first focus. Installing +later also updates existing editors, without changing their note or selection. + +```ts +import { installEditorHost, revealEditorCaret } from '@zennotes/app-core/editor' + +const host = installEditorHost({ + nativeTyping: true, + measureBottomInsets: ({ editor, scroll }) => ({ + // These geometry helpers and overlay elements belong to the native shell. + layout: bottomOverlap(editor, selectionToolbarBounds()), + scroll: bottomOverlap(scroll, keyboardToolbarBounds()) + }) +}) + +// After keyboard resize, overlay mount/removal, or a toolbar size change: +host.refresh() +revealEditorCaret() +// On shell teardown, also cancel the host's observers/listeners/timers: +host.dispose() +``` + +`nativeTyping: true` enables sentence capitalization, autocorrect, spellchecking, +and writing suggestions through the editor's content attributes. The native +keyboard decides which features to provide. It does not install keyboard plugins +or change the host's spelling capabilities. + +The measurement callback receives frozen copies of editor and scroll-viewport +bounds (`top`, `bottom`, `left`, `right`, `width`, `height`) in CSS pixels. It receives +no DOM element or CodeMirror object. Read host geometry there; do not mutate layout +or call configuration APIs from the callback. + +- `layout` reserves physical space below the scroller, keeping native selection + handles above an overlay. Calculate it from the stable `editor` bounds. +- `scroll` adds clearance inside the remaining scroll viewport. Calculate it from + `scroll` bounds to avoid counting an area already reserved by `layout` twice. + +Core remeasures after changing layout clearance and on editor geometry changes. +Hosts call `refresh()` when their overlays change independently. Insets are +clamped to the available height; invalid values and failed measurements clear the +affected clearance. No configuration means the existing editor behavior remains. + +`revealEditorCaret()` returns whether a reveal was scheduled for a focused, active +note editor. It waits for measurement, uses the current caret in that note, and +never takes focus. Pending work is discarded if the note, vault, pane, focus, or +registration changes, or the editor is destroyed. Native keyboard timing and +delayed retries remain host-owned; cancel those timers during teardown. + +The newest registration owns configuration for all mounted and future note +editors. Older handles become no-ops. Disposing the current handle removes its +typing attributes and insets, returning to the underlying editor configuration; +it does not restore an older registration. + +## Editor commands + +Call `runEditorCommand(command)` from the host toolbar. It resolves the actual +active note editor immediately and returns the underlying command's handled +boolean. It returns `false` for an unavailable or transitioning editor, an unknown +command, or an unhandled operation such as Undo with no history. Formatting and +history commands restore editor focus even when there is nothing to change. + +| Commands | Behavior | +| --- | --- | +| `toggle-bold`, `toggle-italic`, `toggle-strikethrough`, `toggle-highlight`, `toggle-inline-code` | Wrap or unwrap every selection using the existing editor rules. | +| `set-bullet-list`, `set-task-list` | Convert the selected lines, or start a list on an empty line while retaining indentation. | +| `cycle-heading` | Choose the next heading level from the main selection's first line (1, 2, 3, then paragraph) and apply it to the selected nonblank lines. | +| `insert-link` | Wrap every selection as a Markdown link and place its caret in the URL. | +| `insert-wikilink`, `insert-tag` | Replace the main selection with `[[]]` or `#` and position a single caret for typing. | +| `indent`, `outdent`, `undo`, `redo` | Use the editor's existing settings and history. | +| `open-search`, `close-search` | Open and focus Find, or close it through the normal search command. | + +Search owns its focus: opening Find leaves its field focused, and closing it +returns focus to the editor only when the search panel held focus. Search also +works in a read-only note; commands that change text are rejected there. The +commands do not require editor focus, so toolbar buttons can receive it first. +Hosts should scope formatting controls to their editing UI. + +`hasEditorSelection()` returns whether any text range is selected in the active +registered editor. It returns `false` when no matching note editor is available. +For mobile swipe/gesture suppression, combine this with a noncollapsed DOM +selection check. Preview text uses DOM selection and must still suppress gestures. + +```ts +import { runEditorCommand, hasEditorSelection } from '@zennotes/app-core/editor' + +runEditorCommand('toggle-bold') +// In Android's back-button cascade: +if (runEditorCommand('close-search')) return +// In a gesture guard shared by Edit and Preview: +const selection = window.getSelection() +const hasSelection = Boolean(selection && !selection.isCollapsed) || hasEditorSelection() +``` + +These commands are synchronous. For a file picker or clipboard read, use the +captured attachment target below rather than running a command after an await. + +## Attachment integration + +Capture a target before opening the file picker or starting an asynchronous +clipboard read. The host binds storage operations to one vault instance: + +```ts +import { captureEditorInsertion, attachFiles } from '@zennotes/app-core/editor' + +const vault = activeVault() // Host-owned storage implementation. +const target = captureEditorInsertion({ + isCurrent: () => activeVault() === vault, + importFile: (notePath, file) => vault.importDroppedFile(notePath, file), + importPastedImage: (input) => vault.importPastedImage(input) +}) +if (target) { + const files = await pickFiles() // Host-owned picker. + const result = await attachFiles(target, files) + // Show the appropriate status below; do not assume every import was inserted. +} +``` + +Load the editor entrypoint after restoring native preferences, like navigation. +Never resolve `activeVault()` inside the two import methods. `isCurrent` checks +the host independently because it may switch vaults before renderer state updates. +Hosts continue to validate vault-relative paths and own filesystem/network I/O. + +The target captures the actual registered note editor, vault, document, and full +selection. It is opaque and single-use. Toolbar actions can capture the active +editor after a button takes focus; keyboard paste can pass `{ requireFocus: true }` +as the second capture argument. A captured target survives picker blur. Call +`cancelEditorInsertion(target)` on picker cancellation or host disposal. +Cancellation prevents further imports and insertion; it cannot undo or abort a +host save already in progress. + +`attachFiles(target, files)` snapshots the list, imports serially, and inserts at +the captured cursor head, preserving selected text. For clipboard images, call +`insertPastedImage(target, input)` after reading the bytes. It replaces the captured +selection. Both use the editor's existing attachment spacing rules and validate +the context before and after each save. A partial batch never inserts partial +Markdown. Successful insertion focuses the captured editor; stale work does not +steal focus or delete saved files. + +| Result status | Meaning | +| --- | --- | +| `inserted` | All confirmed assets were inserted through the normal editor update. | +| `empty` | The file list was empty; nothing was imported or inserted. | +| `stale` | Target unavailable, used, cancelled, or changed before any confirmed save. | +| `saved-only` | Context changed or was cancelled after a confirmed save. Assets remain in the captured vault; no Markdown was inserted. | +| `failed` | Import/insertion failed. `error` describes the failure; `assets` lists confirmed prior saves. | + +Every result includes `assets`. If a host commits a file and then rejects, the +core cannot know that file was saved; it reports only successful return values. +Map `saved-only` and partial failures to visible recovery guidance in the host. + +## Local package candidates + +From the repository root: + +```sh +npm run artifact:app-core +npm run test:app-core-package +npm run test:app-core-browser +``` + +The producer creates three immutable archives in ignored `dist/shared-packages`: +app-core, bridge-contract, and shared-domain. Install all three together. The core +candidate pins its companion packages exactly. Each archive has a SHA-256 and +source record. This is a local validation workflow; it does not publish packages. + +The package contains emitted JavaScript, declarations, compiled CSS, local fonts, +and image assets. Internal imports are relative ESM imports or declared package +dependencies. TypeScript sources, workspace aliases, and sibling checkouts are not +needed by consumers. `main` imports the shared CSS automatically. The source +workspace still uses Tailwind; the installed CSS is already compiled using the +same preset owned by app-core. + +React, ReactDOM, Zustand, CodeMirror state/view/language, and Lezer common/highlight +are peers in the candidate. Hosts must install compatible versions. Lezer's node +property identifiers must come from one shared instance across every parser and +highlighter. The consumer test deliberately uses a nested installation, checks +app-core's peer identity, and verifies one React/CodeMirror/Lezer copy and its +resolution from every declared consumer. Drawing libraries may own independent +Zustand stores. No dedupe aliases hide a second instance. + +### Vite host setup + +```ts +import { defineConfig } from 'vite' +import { zenNotesAssets } from '@zennotes/app-core/vite' + +export default defineConfig({ + plugins: zenNotesAssets(), + base: './' +}) +``` + +The helper resolves the Oniguruma binary as a data URL, resolves Harper's exported +binary entry, and serves/copies Excalidraw fonts. Hosts using native spelling can +pass `{ harper: false }` to omit Harper; they must also advertise +`supportsHarper: false` through their bridge. `{ excalidraw: false }` omits drawing +fonts for hosts that do not support drawings. + +Before opening a drawing, set `window.EXCALIDRAW_ASSET_PATH` to the deployment's +`excalidraw-assets/` URL, including any mount prefix. Install the host bridge and +restore preferences before dynamically importing `main`, `navigation`, `editor`, +`shell`, or `browse`, because each can evaluate application state. The build-only `vite` export does not load +the renderer. Avoid manual chunk rules that pull lazy features into bootstrap. + +### Validation and remaining work + +The package test copies the current HTTP bridge into a separate temporary app, +rewrites its domain imports to public exports, verifies candidate hashes, installs +with a nested dependency tree, typechecks, and builds with Vite. It records the +consumer path in `dist/shared-packages/app-core-consumer.json` and keeps that +directory for inspection. The browser test uses the built app, a temporary vault, +and a temporary Chrome profile. Set `ZEN_CHROME_PATH` when Chrome is not installed +at the platform default. Both tests leave production vaults and settings alone. + +Both native repositories now install exact immutable package archives without a +source clone. Clean builds, isolated Android/iOS runtime fixtures, and cold starts +pass. Live iCloud and account-backed Cloud sync remain staging-account acceptance +gates; browser fixtures alone do not prove those integrations. + + +## Native vault relocation and workspace identity + +`getWorkspaceSnapshot().generation` changes whenever a transition begins, including +one that is cancelled or fails. Capture it with the host's opaque vault identity +before asynchronous UI work, and require both to match before dispatching writes. +Never infer identity from the visible vault name or a remote profile's display root. + +`relocateLocalVault({ move, rollback, reopen })` reserves the workspace before save +draining and keeps it reserved through native filesystem work and reopen. Native +callbacks own platform I/O and must undo their own partial failure before rejecting. +`reopen` contains opaque source/destination tokens and is omitted for a closed vault. +If destination reopen fails, core rolls back, reopens the original token, and +restores the flushed state. If rollback/recovery also fails, core deactivates the +vault, preserves cached drafts, and reports the recovery failure. Editing must not +resume against an uncertain location. Both native bridges use this operation for +vault rename/move; callers must not implement separate flush/move/reopen sequences. diff --git a/packages/app-core/build/tailwind-preset.cjs b/packages/app-core/build/tailwind-preset.cjs new file mode 100644 index 00000000..e94cdda4 --- /dev/null +++ b/packages/app-core/build/tailwind-preset.cjs @@ -0,0 +1,85 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + theme: { + extend: { + colors: { + paper: { + 50: 'rgb(var(--z-bg-softer) / )', + 100: 'rgb(var(--z-bg) / )', + 200: 'rgb(var(--z-bg-1) / )', + 300: 'rgb(var(--z-bg-2) / )', + 400: 'rgb(var(--z-bg-3) / )', + 500: 'rgb(var(--z-bg-4) / )' + }, + ink: { + 900: 'rgb(var(--z-fg) / )', + 800: 'rgb(var(--z-fg-1) / )', + 700: 'rgb(var(--z-fg-2) / )', + 600: 'rgb(var(--z-grey-2) / )', + 500: 'rgb(var(--z-grey-1) / )', + 400: 'rgb(var(--z-grey-0) / )', + 300: 'rgb(var(--z-grey-dim) / )' + }, + accent: { + DEFAULT: 'rgb(var(--z-accent) / )', + soft: 'rgb(var(--z-accent-soft) / )', + muted: 'rgb(var(--z-accent-muted) / )' + }, + danger: 'rgb(var(--z-red) / )', + success: 'rgb(var(--z-green) / )', + warning: 'rgb(var(--z-yellow) / )' + }, + borderRadius: { + // Scale every rounded-* by --z-radius-scale (default 1) so one var can + // square all corners (Quick tweaks → Square corners sets it to 0). + // rounded-none / rounded-full keep Tailwind defaults, so pills and + // circles stay round. + DEFAULT: 'calc(0.25rem * var(--z-radius-scale, 1))', + sm: 'calc(0.125rem * var(--z-radius-scale, 1))', + md: 'calc(0.375rem * var(--z-radius-scale, 1))', + lg: 'calc(0.5rem * var(--z-radius-scale, 1))', + xl: 'calc(0.75rem * var(--z-radius-scale, 1))', + '2xl': 'calc(1rem * var(--z-radius-scale, 1))', + '3xl': 'calc(1.5rem * var(--z-radius-scale, 1))' + }, + fontFamily: { + sans: [ + '-apple-system', + 'BlinkMacSystemFont', + '"SF Pro Text"', + '"Inter"', + 'system-ui', + 'sans-serif' + ], + serif: ['"Iowan Old Style"', '"Source Serif Pro"', 'Georgia', 'serif'], + mono: ['"JetBrains Mono"', '"SF Mono"', 'Menlo', 'monospace'] + }, + boxShadow: { + panel: + '0 1px 0 0 rgb(var(--z-shadow) / 0.04), 0 8px 28px -12px rgb(var(--z-shadow) / 0.18)', + float: '0 20px 60px -20px rgb(var(--z-shadow) / 0.28)' + }, + fontSize: { + '2xs': ['0.6875rem', { lineHeight: '1rem' }] + }, + zIndex: { + dropdown: '40', + palette: '50', + modal: '70', + nested: '75', + popover: '80', + toast: '90' + }, + maxWidth: { + 'dialog-xs': '420px', + 'dialog-sm': '440px', + 'dialog-md': '560px', + 'dialog-lg': '720px', + 'dialog-xl': '900px', + 'dialog-2xl': '1120px', + 'dialog-3xl': '1360px' + } + } + }, + plugins: [] +} diff --git a/packages/app-core/build/vite.d.ts b/packages/app-core/build/vite.d.ts new file mode 100644 index 00000000..c84a23b9 --- /dev/null +++ b/packages/app-core/build/vite.d.ts @@ -0,0 +1,11 @@ +import type { Plugin } from 'vite' + +export interface ZenNotesAssetsOptions { + /** Disable the grammar checker and its binary for hosts using native spelling. */ + harper?: boolean + /** Serve drawing fonts locally when the host supports Excalidraw. */ + excalidraw?: boolean +} + +/** Build integrations for the shared editor's lazy assets. */ +export function zenNotesAssets(options?: ZenNotesAssetsOptions): Plugin[] diff --git a/packages/app-core/build/vite.mjs b/packages/app-core/build/vite.mjs new file mode 100644 index 00000000..02b2ad6d --- /dev/null +++ b/packages/app-core/build/vite.mjs @@ -0,0 +1,67 @@ +import { createReadStream, readFileSync, readdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, relative, resolve, sep } from 'node:path' + +const require = createRequire(import.meta.url) +const harperWasm = 'harper.js/dist/harper_wasm_slim_bg.wasm?url' +const onigWasm = 'vscode-oniguruma/release/onig.wasm?url' + +/** @type {typeof import('./vite').zenNotesAssets} */ +export function zenNotesAssets(options = {}) { + const onigVirtual = '\0zennotes-core:oniguruma' + const harperVirtual = '\0zennotes-core:harper-disabled' + const assets = { + name: 'zennotes-core-assets', + enforce: 'pre', + async resolveId(id, importer) { + if (id === onigWasm) return onigVirtual + if (options.harper === false && (id === 'harper.js' || id === harperWasm)) return harperVirtual + if (id !== harperWasm) return null + const entry = await this.resolve('harper.js/slimBinary', importer, { skipSelf: true }) + if (!entry) throw new Error('Cannot locate the installed Harper binary') + return join(dirname(entry.id.split('?')[0]), 'harper_wasm_slim_bg.wasm') + '?url' + }, + load(id) { + if (id === harperVirtual) return 'export default ""' + if (id !== onigVirtual) return null + const bytes = readFileSync(require.resolve('vscode-oniguruma/release/onig.wasm')) + return `export default ${JSON.stringify(`data:application/wasm;base64,${bytes.toString('base64')}`)}` + } + } + if (options.excalidraw === false) return [assets] + + const fonts = join(dirname(require.resolve('@excalidraw/excalidraw')), 'fonts') + let base = '/' + return [assets, { + name: 'zennotes-core-drawing-fonts', + configResolved(config) { base = config.base }, + configureServer(server) { + const prefix = `${base === './' || base === '' ? '/' : base}excalidraw-assets/fonts/` + server.middlewares.use((req, res, next) => { + const path = req.url?.split('?')[0] + if (!path?.startsWith(prefix)) return next() + let file + try { file = resolve(fonts, decodeURIComponent(path.slice(prefix.length))) } + catch { res.statusCode = 400; res.end(); return } + if (!file.startsWith(fonts + sep) || !/\.(woff2?|otf|ttf)$/i.test(file)) { + res.statusCode = 404; res.end(); return + } + const mime = file.endsWith('.woff2') ? 'font/woff2' : file.endsWith('.woff') ? 'font/woff' : file.endsWith('.otf') ? 'font/otf' : 'font/ttf' + res.setHeader('Content-Type', mime) + createReadStream(file).on('error', () => { res.statusCode = 404; res.end() }).pipe(res) + }) + }, + generateBundle() { + const walk = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const file = join(directory, entry.name) + if (entry.isDirectory()) walk(file) + else if (entry.isFile() && /\.(woff2?|otf|ttf)$/i.test(file)) { + this.emitFile({ type: 'asset', fileName: `excalidraw-assets/fonts/${relative(fonts, file).split(sep).join('/')}`, source: readFileSync(file) }) + } + } + } + walk(fonts) + } + }] +} diff --git a/packages/app-core/package.json b/packages/app-core/package.json index bf1e194a..ce9e57b6 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,12 +1,30 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.50.4", + "version": "2.51.0", "type": "module", "exports": { - "./main": "./src/main.tsx" + "./main": "./src/main.tsx", + "./navigation": "./src/navigation.ts", + "./notes": "./src/notes.ts", + "./shell": "./src/shell.ts", + "./browse": "./src/browse.ts", + "./editor": "./src/editor.ts", + "./vite": { + "types": "./build/vite.d.ts", + "import": "./build/vite.mjs" + }, + "./styles.css": "./src/styles/index.css", + "./tasks": "./src/tasks.ts", + "./workspace": "./src/workspace.ts", + "./settings": "./src/settings.ts", + "./commands": "./src/commands.ts", + "./dialogs": "./src/dialogs.ts", + "./host": "./src/host.ts" }, "dependencies": { + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-cpp": "^6.0.3", @@ -30,6 +48,7 @@ "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@excalidraw/excalidraw": "^0.18.1", + "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.1", "@myriaddreamin/typst-ts-renderer": "^0.7.0", "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", @@ -66,7 +85,14 @@ }, "devDependencies": { "vite": "^6.4.3", - "vitest": "^3.2.6" + "vitest": "^3.2.6", + "typescript": "^5.7.2", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "autoprefixer": "^10.4.20", + "vfile": "^6.0.3", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7" }, "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 18da506e..11453335 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -1,3 +1,4 @@ +import { requestSettingsTarget } from './lib/settings-navigation' import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react' import { useStore, @@ -254,6 +255,11 @@ function AppUpdateNotice({ void window.zen.downloadAppUpdate() return } + if (updateState?.phase === 'error') { + requestSettingsTarget('about') + useStore.getState().setSettingsOpen(true) + return + } if (updateState?.phase === 'downloaded') { void window.zen.installAppUpdate() } @@ -266,7 +272,7 @@ function AppUpdateNotice({ className="fixed bottom-4 right-4 z-40 flex max-w-[min(28rem,calc(100vw-2rem))] items-center gap-2 rounded-xl border border-accent/30 bg-paper-50/95 px-3 py-2 text-sm text-ink-800 shadow-float backdrop-blur" > - {label} + {label} {updateState?.phase === 'downloading' && ( {Math.round(updateState.progressPercent ?? 0)}% @@ -346,10 +352,10 @@ function App(): JSX.Element { const mountedAtRef = useRef(performance.now()) const workspaceReadyLoggedRef = useRef(false) const searchPaletteWarmupCleanupRef = useRef<(() => void) | null>(null) - const pendingOpenNoteRequestsRef = useRef([]) + const pendingOpenNoteRequestsRef = useRef['vault'] }>>([]) const vault = useStore((s) => s.vault) const init = useStore((s) => s.init) - const workspaceRestored = useStore((s) => s.workspaceRestored) + const workspaceRestored = useStore((s) => s.workspaceRestored && !s.workspaceTransitioning) const searchOpen = useStore((s) => s.searchOpen) const setSearchOpen = useStore((s) => s.setSearchOpen) const vaultTextSearchOpen = useStore((s) => s.vaultTextSearchOpen) @@ -464,11 +470,11 @@ function App(): JSX.Element { useEffect(() => { return window.zen.onOpenNoteRequested((relPath) => { const state = useStore.getState() - if (state.vault && state.workspaceRestored) { + if (state.vault && state.workspaceRestored && !state.workspaceTransitioning) { void state.openNoteInTab(relPath) return } - pendingOpenNoteRequestsRef.current.push(relPath) + pendingOpenNoteRequestsRef.current.push({ path: relPath, vault: state.vault }) }) }, []) @@ -529,8 +535,9 @@ function App(): JSX.Element { useEffect(() => { if (!vault || !workspaceRestored || pendingOpenNoteRequestsRef.current.length === 0) return const requests = pendingOpenNoteRequestsRef.current.splice(0) - for (const relPath of requests) { - void useStore.getState().openNoteInTab(relPath) + for (const request of requests) { + if (request.vault && request.vault !== vault) continue + void useStore.getState().openNoteInTab(request.path) } }, [vault, workspaceRestored]) diff --git a/packages/app-core/src/asset-actions.test.ts b/packages/app-core/src/asset-actions.test.ts new file mode 100644 index 00000000..2487cef0 --- /dev/null +++ b/packages/app-core/src/asset-actions.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeLeaf } from './lib/pane-layout' + +beforeEach(() => { vi.resetModules(); localStorage.clear() }) +function deferred() { + let resolve!: () => void + const promise = new Promise(r => { resolve = r }) + return { promise, resolve } +} +async function setup() { + const path = 'inbox/Note.md' + let disk = '![image](attachements/old.png)' + const meta = { path, title: 'Note', folder: 'inbox' as const, siblingOrder: 0, + createdAt: 0, updatedAt: 1, size: disk.length, tags: [], wikilinks: [], + assetEmbeds: [], hasAttachments: true, excerpt: '' } + const asset = { path: 'attachements/new.png', name: 'new.png', size: 1, updatedAt: 2 } + const bridge = { + getCapabilities: () => ({}), getAppInfo: () => ({ runtime: 'web' }), + listNotes: async () => [meta], listFolders: async () => [], + listAssets: async () => [], hasAssetsDir: async () => true, + scanTasks: async () => [], scanTasksForPath: async () => [], + getRemoteWorkspaceInfo: async () => null, + readNote: async () => ({ ...meta, body: disk }), + writeNote: vi.fn(async (_path: string, body: string) => { disk = body; return meta }), + renameAsset: vi.fn(async () => { disk = disk.replace('old.png', 'new.png'); return asset }), + moveAsset: vi.fn(async () => { disk = disk.replace('old.png', 'new.png'); return asset }), + openLocalVault: vi.fn().mockResolvedValue(null) + } + Object.defineProperty(window, 'zen', { configurable: true, value: bridge }) + const { useStore } = await import('./store') + const leaf = makeLeaf([path], path) + useStore.setState({ vault: { root: '/test', name: 'Test' }, notes: [meta], + paneLayout: leaf, activePaneId: leaf.id, selectedPath: path, + noteContents: { [path]: { ...meta, body: disk + '\nUnsaved edit' } }, + noteDirty: { [path]: true }, activeNote: { ...meta, body: disk + '\nUnsaved edit' }, activeDirty: true }) + return { useStore, bridge, path, asset, disk: () => disk } +} + +describe('asset link rewrites across workspace boundaries', () => { + it.each(['renameAsset', 'moveAsset'] as const)('%s drains edits, reserves the vault and refreshes open bodies', async action => { + const s = await setup(), saving = deferred(), moving = deferred() + const write = s.bridge.writeNote.getMockImplementation()! + s.bridge.writeNote.mockImplementation(async (...args) => { await saving.promise; return write(...args) }) + const rewrite = s.bridge[action].getMockImplementation()! + s.bridge[action].mockImplementation(async () => { await moving.promise; return rewrite() }) + const pending = s.useStore.getState()[action]('attachements/old.png', 'new.png') + expect(s.useStore.getState().workspaceTransitioning).toBe(true) + await s.useStore.getState().openLocalVault('/other') + expect(s.bridge.openLocalVault).not.toHaveBeenCalled() + expect(s.bridge[action]).not.toHaveBeenCalled() + saving.resolve() + await vi.waitFor(() => expect(s.bridge[action]).toHaveBeenCalledOnce()) + s.useStore.getState().updateNoteBody(s.path, 'Typing during rewrite') + expect(s.useStore.getState().noteContents[s.path].body).toContain('Unsaved edit') + await expect(s.useStore.getState()[action]('attachements/old.png', 'other.png')).rejects.toThrow('Wait') + moving.resolve() + expect(await pending).toEqual(s.asset) + expect(s.disk()).toBe('![image](attachements/new.png)\nUnsaved edit') + expect(s.useStore.getState().activeNote?.body).toBe(s.disk()) + expect(s.useStore.getState().workspaceTransitioning).toBe(false) + s.useStore.getState().updateNoteBody(s.path, s.disk() + '\nAfter rewrite') + await s.useStore.getState().persistNote(s.path) + expect(s.disk()).toBe('![image](attachements/new.png)\nUnsaved edit\nAfter rewrite') + }) + it('does not rewrite links when the save drain fails', async () => { + const s = await setup() + s.bridge.writeNote.mockRejectedValue(new Error('disk full')) + await expect(s.useStore.getState().renameAsset('old.png', 'new.png')).rejects.toThrow('unsaved') + expect(s.bridge.renameAsset).not.toHaveBeenCalled() + expect(s.useStore.getState().noteDirty[s.path]).toBe(true) + expect(s.useStore.getState().workspaceTransitioning).toBe(false) + }) + it('releases editing and the workspace reservation after a host failure', async () => { + const s = await setup() + s.bridge.moveAsset.mockRejectedValue(new Error('permission denied')) + await expect(s.useStore.getState().moveAsset('old.png', 'folder')).rejects.toThrow('permission denied') + expect(s.useStore.getState().workspaceTransitioning).toBe(false) + s.useStore.getState().updateNoteBody(s.path, 'Recovered edit') + expect(s.useStore.getState().noteContents[s.path].body).toBe('Recovered edit') + }) +}) diff --git a/packages/app-core/src/browse-actions.test.ts b/packages/app-core/src/browse-actions.test.ts new file mode 100644 index 00000000..001df601 --- /dev/null +++ b/packages/app-core/src/browse-actions.test.ts @@ -0,0 +1,290 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { + configurable: true, + value: { getCapabilities: () => ({}) } + }) +}) + +async function setup() { + const { useStore } = await import('./store') + const actions = await import('./lib/browse-actions') + const prompts = await import('./lib/prompt-requests') + const confirms = await import('./lib/confirm-requests') + const create = vi.fn(async () => {}) + const rename = vi.fn(async () => {}) + const remove = vi.fn(async () => {}) + const createDatabase = vi.fn(async () => {}) + const renameDatabase = vi.fn(async () => {}) + useStore.setState({ + vault: { root: '/test', name: 'Test' }, + folders: ['Work', 'Work/Nested', 'People.base'].map((subpath) => ({ + folder: 'inbox', + subpath, + siblingOrder: 0 + })), + createFolder: create, + renameFolder: rename, + deleteFolder: remove, + createDatabase, + renameDatabase + }) + const host = { isCurrent: () => true } + const answer = (value: string | null) => { + const request = prompts.getPromptRequest() + expect(request).not.toBeNull() + prompts.settlePromptRequest(request!, value) + } + const confirm = (value: boolean) => { + const request = confirms.getConfirmRequest() + expect(request).not.toBeNull() + confirms.settleConfirmRequest(request!, value) + } + return { + useStore, + ...actions, + ...prompts, + ...confirms, + create, + rename, + remove, + createDatabase, + renameDatabase, + host, + answer, + confirm + } +} + +describe('public Browse actions', () => { + it('creates a trimmed child through the normal store action', async () => { + const s = await setup() + const result = s.requestCreateBrowseFolder(s.host, 'Work') + expect(s.getPromptRequest()?.options.title).toBe('New folder in Work') + s.answer(' Research ') + expect(await result).toBe('completed') + expect(s.create).toHaveBeenCalledWith('inbox', 'Work/Research', expect.any(Function)) + }) + + it('renames only the leaf and retains the parent', async () => { + const s = await setup() + const result = s.requestRenameBrowseFolder(s.host, 'Work/Nested') + expect(s.getPromptRequest()?.options.initialValue).toBe('Nested') + s.answer('Renamed') + expect(await result).toBe('completed') + expect(s.rename).toHaveBeenCalledWith( + 'inbox', + 'Work/Nested', + 'Work/Renamed', + expect.any(Function) + ) + }) + + it('cancels names without writes and refuses invalid names at submission', async () => { + const s = await setup() + for (const value of [ + null, + '', + ' ', + '../Elsewhere', + 'a/b', + 'a\\b', + '.', + '..', + 'New.base', + 'bad\0name' + ]) { + const result = s.requestCreateBrowseFolder(s.host) + if (value?.trim()) expect(s.getPromptRequest()?.options.validate?.(value)).toBeTruthy() + s.answer(value) + expect(await result).toBe('cancelled') + } + const same = s.requestRenameBrowseFolder(s.host, 'Work') + s.answer(' Work ') + expect(await same).toBe('cancelled') + expect(s.create).not.toHaveBeenCalled() + expect(s.rename).not.toHaveBeenCalled() + }) + + it('requires confirmation for folder and database deletion with distinct explanations', async () => { + const s = await setup() + const cancelled = s.requestDeleteBrowseDirectory(s.host, 'Work') + expect(s.getConfirmRequest()?.options).toMatchObject({ + danger: true, + description: expect.stringContaining('Everything inside') + }) + s.confirm(false) + expect(await cancelled).toBe('cancelled') + expect(s.remove).not.toHaveBeenCalled() + const deleted = s.requestDeleteBrowseDirectory(s.host, 'People.base') + expect(s.getConfirmRequest()?.options).toMatchObject({ + title: 'Delete "People"?', + description: expect.stringContaining('All records') + }) + s.confirm(true) + expect(await deleted).toBe('completed') + expect(s.remove).toHaveBeenCalledWith('inbox', 'People.base', expect.any(Function)) + }) + + it('rejects roots, missing folders, database internals, and database renames', async () => { + const s = await setup() + for (const directory of ['', 'Missing', 'People.base/pages']) { + expect(await s.requestDeleteBrowseDirectory(s.host, directory)).toBe('unavailable') + expect(await s.requestRenameBrowseFolder(s.host, directory)).toBe('unavailable') + } + expect(await s.requestRenameBrowseFolder(s.host, 'People.base')).toBe('unavailable') + expect(await s.requestCreateBrowseFolder(s.host, 'People.base')).toBe('unavailable') + expect(s.getPromptRequest()).toBeNull() + expect(s.getConfirmRequest()).toBeNull() + }) + + it.each(['vault', 'host', 'layout', 'missing'] as const)( + 'stops a pending action after a %s context change', + async (kind) => { + const s = await setup() + let current = true + const result = s.requestDeleteBrowseDirectory({ isCurrent: () => current }, 'Work') + if (kind === 'vault') s.useStore.setState({ vault: { root: '/other', name: 'Other' } }) + if (kind === 'host') current = false + if (kind === 'layout') + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation: 'root' + } + }) + if (kind === 'missing') s.useStore.setState({ folders: [] }) + s.confirm(true) + expect(await result).toBe('stale') + expect(s.remove).not.toHaveBeenCalled() + } + ) + + it('does not replace an existing dialog or start two Browse requests', async () => { + const s = await setup() + const first = s.requestCreateBrowseFolder(s.host) + const original = s.getPromptRequest() + expect(await s.requestDeleteBrowseDirectory(s.host, 'Work')).toBe('unavailable') + expect(s.getPromptRequest()).toBe(original) + s.answer(null) + await first + const other = s.promptApp({ title: 'Unrelated prompt' }) + expect(await s.requestCreateBrowseFolder(s.host)).toBe('unavailable') + s.answer(null) + await other + }) + + it('rejects host errors and releases the pending action', async () => { + const s = await setup() + s.create.mockRejectedValueOnce(new Error('Read-only vault')) + const failed = s.requestCreateBrowseFolder(s.host) + s.answer('New') + await expect(failed).rejects.toThrow('Read-only vault') + const next = s.requestCreateBrowseFolder(s.host) + s.answer(null) + expect(await next).toBe('cancelled') + }) + it('creates an untitled database in the explicit Browse directory without a prompt', async () => { + const s = await setup() + expect(await s.createBrowseDatabase(s.host, 'Work')).toBe('completed') + expect(s.createDatabase).toHaveBeenCalledWith('inbox', 'Work', undefined, expect.any(Function)) + expect(s.getPromptRequest()).toBeNull() + expect(await s.createBrowseDatabase(s.host, 'People.base')).toBe('unavailable') + expect(await s.createBrowseDatabase(s.host, 'Missing')).toBe('unavailable') + }) + + it.each([false, true])('renames a database with primary root mode %s', async (root) => { + const s = await setup() + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation: root ? 'root' : 'inbox', + systemFolderPaths: { inbox: 'My Notes' } + } + }) + const result = s.requestRenameBrowseDatabase(s.host, 'People.base') + expect(s.getPromptRequest()?.options.initialValue).toBe('People') + s.answer(' Customers ') + expect(await result).toBe('completed') + expect(s.renameDatabase).toHaveBeenCalledWith( + root ? 'People.base/data.csv' : 'My Notes/People.base/data.csv', + 'Customers', + expect.any(Function) + ) + }) + + it('cancels invalid database names and refuses folders or database internals', async () => { + const s = await setup() + for (const directory of ['', 'Work', 'People.base/pages']) + expect(await s.requestRenameBrowseDatabase(s.host, directory)).toBe('unavailable') + for (const title of [null, ' ', 'People', '.', '..', '.Hidden', '../Elsewhere', 'a\\b', 'bad\0name']) { + const result = s.requestRenameBrowseDatabase(s.host, 'People.base') + s.answer(title) + expect(await result).toBe('cancelled') + } + expect(s.renameDatabase).not.toHaveBeenCalled() + }) + + it('stops a database rename when its host identity changes during the prompt', async () => { + const s = await setup() + let current = true + const result = s.requestRenameBrowseDatabase({ isCurrent: () => current }, 'People.base') + current = false + s.answer('Customers') + expect(await result).toBe('stale') + expect(s.renameDatabase).not.toHaveBeenCalled() + }) + + it('propagates a database create failure and releases the action guard', async () => { + const s = await setup() + s.createDatabase.mockRejectedValueOnce(new Error('No space')) + await expect(s.createBrowseDatabase(s.host)).rejects.toThrow('No space') + expect(await s.createBrowseDatabase(s.host)).toBe('completed') + }) + + it('honors configured database placement when directory is omitted, and explicit root overrides it', async () => { + const s = await setup() + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + databasesLocation: { mode: 'folder', folder: 'Databases' } + } + }) + expect(await s.createBrowseDatabase(s.host)).toBe('completed') + expect(s.createDatabase).toHaveBeenLastCalledWith( + 'inbox', + 'Databases', + undefined, + expect.any(Function) + ) + expect(await s.createBrowseDatabase(s.host, '')).toBe('completed') + expect(s.createDatabase).toHaveBeenLastCalledWith('inbox', '', undefined, expect.any(Function)) + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + databasesLocation: { mode: 'active-note' } + }, + activeNote: { path: 'quick/Project/Note.md', folder: 'quick' } as never + }) + expect(await s.createBrowseDatabase(s.host)).toBe('completed') + expect(s.createDatabase).toHaveBeenLastCalledWith( + 'quick', + 'Project', + undefined, + expect.any(Function) + ) + }) + it('retains legacy configured placement in an active database record folder', async () => { + const s = await setup() + s.useStore.setState({ vaultSettings: { ...s.useStore.getState().vaultSettings, databasesLocation: { mode: 'active-note' } }, activeNote: { path: 'inbox/People.base/Record.md', folder: 'inbox' } as never }) + expect(await s.createBrowseDatabase(s.host)).toBe('completed') + expect(s.createDatabase).toHaveBeenLastCalledWith('inbox', 'People.base', undefined, expect.any(Function)) + expect(await s.createBrowseDatabase(s.host, 'People.base')).toBe('unavailable') + }) + +}) diff --git a/packages/app-core/src/browse.test.ts b/packages/app-core/src/browse.test.ts new file mode 100644 index 00000000..073a5973 --- /dev/null +++ b/packages/app-core/src/browse.test.ts @@ -0,0 +1,286 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderEntry, NoteMeta } from '@bridge-contract/ipc' +import { databaseTabPath } from '@shared/databases' + +const disposers: Array<() => void> = [] +const folder = ( + subpath: string, + kind: FolderEntry['folder'] = 'inbox' +): FolderEntry => ({ folder: kind, subpath, siblingOrder: 0 }) +const note = (path: string): NoteMeta => ({ + path, + title: path.split('/').pop()!.replace(/\.md$/, ''), + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '' +}) + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { + configurable: true, + value: { getCapabilities: () => ({}) } + }) +}) +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose() +}) + +async function setup(folders: FolderEntry[] = []) { + const { useStore } = await import('./store') + const browse = await import('./browse') + const shell = await import('./shell') + useStore.setState({ + vault: { root: '/test', name: 'Test' }, + folders, + notes: [note('inbox/One.md')], + noteSortOrder: 'name-asc' + }) + return { useStore, ...browse, ...shell } +} + +describe('public Browse model', () => { + it('copies and freezes folder/database metadata without exposing mutable source entries or settings', async () => { + const s = await setup([folder('Work'), folder('People.base')]) + const snapshot = s.getBrowseSnapshot() + expect(snapshot.folders).toEqual([{ directory: 'Work', title: 'Work' }]) + expect(snapshot.databases).toEqual([ + { + directory: 'People.base', + title: 'People', + path: databaseTabPath('inbox/People.base/data.csv') + } + ]) + expect(snapshot.notes).toBe(s.getShellSnapshot().notes) + expect(snapshot).not.toHaveProperty('vaultSettings') + expect(snapshot.folders[0]).not.toBe(s.useStore.getState().folders[0]) + for (const value of [ + snapshot, + snapshot.folders, + snapshot.databases, + snapshot.folders[0], + snapshot.databases[0], + snapshot.dateDirectories + ]) + expect(Object.isFrozen(value)).toBe(true) + expect(() => + Object.assign(snapshot.folders[0], { title: 'Changed' }) + ).toThrow() + expect(s.useStore.getState().folders[0].subpath).toBe('Work') + }) + + it('lists only immediate children, preserves empty folders, and deduplicates folder entries', async () => { + const s = await setup([ + folder(''), + folder('Empty'), + folder('Work'), + folder('Work'), + folder('Work/Nested'), + folder('Saved', 'archive'), + folder('Deleted', 'trash') + ]) + const root = s.getBrowseDirectory(s.getBrowseSnapshot()) + expect(root.folders.map((row) => row.directory)).toEqual(['Empty', 'Work']) + expect(root.notes.map((row) => row.title)).toEqual(['One']) + expect( + s + .getBrowseDirectory(s.getBrowseSnapshot(), 'Work') + .folders.map((row) => row.directory) + ).toEqual(['Work/Nested']) + expect(s.getBrowseDirectory(s.getBrowseSnapshot(), 'Empty')).toEqual({ + folders: [], + databases: [], + notes: [] + }) + }) + + it('keeps pinned folders and notes at the front of their own sorted groups', async () => { + const s = await setup([ + folder('Zulu'), + folder('Alpha'), + folder('Beta'), + folder('Zoo.base'), + folder('Accounts.base') + ]) + s.useStore.setState({ + notes: [note('inbox/B.md'), note('inbox/C.md'), note('inbox/A.md')] + }) + const rows = s.getBrowseDirectory(s.getBrowseSnapshot(), '', { + folders: ['Zulu', 'Zoo.base', 'Gone'], + notes: ['inbox/C.md'] + }) + expect(rows.folders.map((row) => row.title)).toEqual([ + 'Zulu', + 'Alpha', + 'Beta' + ]) + expect(rows.databases.map((row) => row.title)).toEqual(['Accounts', 'Zoo']) + expect(rows.notes.map((row) => row.title)).toEqual(['C', 'A', 'B']) + for (const value of [rows, rows.folders, rows.databases, rows.notes]) + expect(Object.isFrozen(value)).toBe(true) + }) + + it('never exposes the contents of database directories as Browse folders or note rows', async () => { + const s = await setup([ + folder('People.BASE'), + folder('People.BASE/pages'), + folder('People.BASE/pages/Nested'), + folder('People.BASE/Other.base'), + folder('People.base-notes') + ]) + s.useStore.setState({ notes: [note('inbox/People.BASE/pages/Hidden.md')] }) + const snapshot = s.getBrowseSnapshot() + expect(snapshot.folders.map((row) => row.directory)).toEqual([ + 'People.base-notes' + ]) + expect(snapshot.databases.map((row) => row.directory)).toEqual([ + 'People.BASE' + ]) + for (const directory of [ + 'People.BASE', + 'People.BASE/pages', + 'People.BASE/pages/Nested' + ]) + expect(s.getBrowseDirectory(snapshot, directory)).toEqual({ + folders: [], + databases: [], + notes: [] + }) + }) + + it.each(['inbox', 'root'] as const)( + 'composes encoded database targets in %s mode with a custom primary path', + async (primaryNotesLocation) => { + const s = await setup([folder('Work/People & café.BASE')]) + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation, + systemFolderPaths: { inbox: '01 - Notes' } + } + }) + const row = s.getBrowseDirectory(s.getBrowseSnapshot(), 'Work') + .databases[0] + const prefix = primaryNotesLocation === 'root' ? '' : '01 - Notes/' + expect(row).toEqual({ + directory: 'Work/People & café.BASE', + title: 'People & café', + path: databaseTabPath(`${prefix}Work/People & café.BASE/data.csv`) + }) + } + ) + + it('reports enabled date directories without exposing or mutating date settings', async () => { + const s = await setup() + const settings = s.useStore.getState().vaultSettings + s.useStore.setState({ + vaultSettings: { + ...settings, + dailyNotes: { + ...settings.dailyNotes, + enabled: true, + directory: 'Journal/Daily' + }, + weeklyNotes: { + ...settings.weeklyNotes, + enabled: false, + directory: 'Journal/Weekly' + }, + monthlyNotes: { + ...settings.monthlyNotes, + enabled: true, + directory: 'Journal/Monthly' + } + } + }) + const before = s.getBrowseSnapshot() + expect(before.dateDirectories).toEqual({ + daily: 'Journal/Daily', + weekly: null, + monthly: 'Journal/Monthly' + }) + const current = s.useStore.getState().vaultSettings + s.useStore.setState({ + vaultSettings: { + ...current, + dailyNotes: { ...current.dailyNotes, enabled: false } + } + }) + expect(s.getBrowseSnapshot().dateDirectories.daily).toBeNull() + expect(before.dateDirectories.daily).toBe('Journal/Daily') + }) + + it('retains identity during selection and editor changes and refreshes after folder changes', async () => { + const s = await setup([folder('Work')]) + const before = s.getBrowseSnapshot() + s.useStore.setState({ + selectedPath: 'inbox/One.md', + activeDirty: true, + editorFontSize: 25 + }) + expect(s.getBrowseSnapshot()).toBe(before) + s.useStore.setState({ folders: [folder('Renamed')] }) + expect(s.getBrowseSnapshot().folders[0].title).toBe('Renamed') + expect(before.folders[0].title).toBe('Work') + expect(s.getBrowseSnapshot().notes).toBe(before.notes) + }) + + it('preserves folder array identity when only notes or unrelated settings change', async () => { + const s = await setup([folder('Work')]) + const before = s.getBrowseSnapshot() + s.useStore.setState({ + notes: [note('inbox/New.md')], + vaultSettings: { ...s.useStore.getState().vaultSettings, folderIcons: {} } + }) + const after = s.getBrowseSnapshot() + expect(after.folders).toBe(before.folders) + expect(after.databases).toBe(before.databases) + expect(after.dateDirectories).toBe(before.dateDirectories) + expect(after.notes[0].title).toBe('New') + }) + + it('updates database paths after layout changes without changing delivered snapshots', async () => { + const s = await setup([folder('People.base')]) + const before = s.getBrowseSnapshot() + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation: 'root' + } + }) + expect(s.getBrowseSnapshot().databases[0].path).toBe( + databaseTabPath('People.base/data.csv') + ) + expect(before.databases[0].path).toBe( + databaseTabPath('inbox/People.base/data.csv') + ) + }) + + it('notifies only on Browse changes and stops after disposal', async () => { + const s = await setup() + const before = s.getBrowseSnapshot() + const listener = vi.fn() + const dispose = s.subscribeBrowse(listener) + disposers.push(dispose) + s.useStore.setState({ selectedPath: 'inbox/One.md' }) + expect(listener).not.toHaveBeenCalled() + s.useStore.setState({ folders: [folder('New')] }) + expect(listener).toHaveBeenCalledExactlyOnceWith( + s.getBrowseSnapshot(), + before + ) + dispose() + s.useStore.setState({ folders: [] }) + expect(listener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app-core/src/browse.ts b/packages/app-core/src/browse.ts new file mode 100644 index 00000000..d5b46f5a --- /dev/null +++ b/packages/app-core/src/browse.ts @@ -0,0 +1,184 @@ +import { useSyncExternalStore } from 'react' +import type { FolderEntry } from '@bridge-contract/ipc' +import { + csvPathForFormDir, + databaseTabPath, + formDirContaining, + formTitleFromDir, + isFormDirName +} from '@shared/databases' +import { resolveFolderPath } from '@shared/system-folder-paths' +import { useStore } from './store' +import { + getBrowseNotes, + getShellSnapshot, + type NoteSortOrder, + type ShellNote, + type ShellSnapshot +} from './shell' +import { parentDirOf } from './lib/manual-order' + +export interface BrowseFolder { + /** Relative to the primary notes area, not the vault root. */ + readonly directory: string + readonly title: string +} + +export interface BrowseDatabase extends BrowseFolder { + /** Opaque application path; pass to the public navigation openNote action. */ + readonly path: string +} + +export interface BrowseSnapshot { + /** Display/change metadata. Native persistence uses the host's stable vault token. */ + readonly vault: ShellSnapshot['vault'] + readonly folders: readonly BrowseFolder[] + readonly databases: readonly BrowseDatabase[] + readonly notes: readonly ShellNote[] + readonly noteSortOrder: NoteSortOrder + /** Enabled directory settings, unchanged; null when disabled. Patterns are not expanded. */ + readonly dateDirectories: Readonly<{ + daily: string | null + weekly: string | null + monthly: string | null + }> +} + +export interface BrowsePins { + readonly notes?: readonly string[] + readonly folders?: readonly string[] +} + +export interface BrowseDirectory { + readonly folders: readonly BrowseFolder[] + readonly databases: readonly BrowseDatabase[] + readonly notes: readonly ShellNote[] +} + +let folderSource: readonly FolderEntry[] | undefined +let primaryDirectory = '' +let folders: readonly BrowseFolder[] = Object.freeze([]) +let databases: readonly BrowseDatabase[] = Object.freeze([]) +let dates: BrowseSnapshot['dateDirectories'] = Object.freeze({ + daily: null, + weekly: null, + monthly: null +}) +let snapshot: BrowseSnapshot | undefined + +export function getBrowseSnapshot(): BrowseSnapshot { + const state = useStore.getState() + const shell = getShellSnapshot() + const settings = state.vaultSettings + const primary = + settings.primaryNotesLocation === 'root' + ? '' + : resolveFolderPath('inbox', settings.systemFolderPaths) + if (folderSource !== state.folders || primaryDirectory !== primary) { + folderSource = state.folders + primaryDirectory = primary + const folderRows = new Map() + const databaseRows = new Map() + for (const entry of state.folders) { + const directory = entry.subpath + if (entry.folder !== 'inbox' || !directory || formDirContaining(parentDirOf(directory))) + continue + if (isFormDirName(directory)) { + const path = primary ? `${primary}/${directory}` : directory + databaseRows.set( + directory, + Object.freeze({ + directory, + title: formTitleFromDir(directory), + path: databaseTabPath(csvPathForFormDir(path)) + }) + ) + } else { + folderRows.set(directory, Object.freeze({ directory, title: directory.split('/').pop()! })) + } + } + folders = Object.freeze([...folderRows.values()]) + databases = Object.freeze([...databaseRows.values()]) + } + const daily = settings.dailyNotes.enabled ? settings.dailyNotes.directory : null + const weekly = settings.weeklyNotes.enabled ? settings.weeklyNotes.directory : null + const monthly = settings.monthlyNotes.enabled ? settings.monthlyNotes.directory : null + if (daily !== dates.daily || weekly !== dates.weekly || monthly !== dates.monthly) + dates = Object.freeze({ daily, weekly, monthly }) + const next: BrowseSnapshot = { + vault: shell.vault, + notes: shell.notes, + noteSortOrder: shell.noteSortOrder, + folders, + databases, + dateDirectories: dates + } + if ( + !snapshot || + (Object.keys(next) as Array).some((key) => next[key] !== snapshot![key]) + ) + snapshot = Object.freeze(next) + return snapshot +} + +/** Observe Browse data changes without notifications for editor selection or cursor changes. */ +export function subscribeBrowse( + listener: (snapshot: BrowseSnapshot, previous: BrowseSnapshot) => void +): () => void { + let previous = getBrowseSnapshot() + return useStore.subscribe(() => { + const next = getBrowseSnapshot() + if (next === previous) return + const before = previous + previous = next + listener(next, before) + }) +} + +function subscribeReact(notify: () => void): () => void { + return subscribeBrowse(() => notify()) +} + +export function useBrowseSnapshot(): BrowseSnapshot { + return useSyncExternalStore(subscribeReact, getBrowseSnapshot, getBrowseSnapshot) +} + +/** Immediate mobile Browse rows, with separate folder, database, and note groups. */ +export function getBrowseDirectory( + snapshot: BrowseSnapshot, + directory = '', + pins: BrowsePins = {} +): BrowseDirectory { + if (formDirContaining(directory)) + return Object.freeze({ + folders: Object.freeze([]), + databases: Object.freeze([]), + notes: Object.freeze([]) + }) + const childFolders = snapshot.folders + .filter((row) => parentDirOf(row.directory) === directory) + .sort((a, b) => a.title.localeCompare(b.title)) + const pinned = new Set(pins.folders) + return Object.freeze({ + folders: Object.freeze([ + ...childFolders.filter((row) => pinned.has(row.directory)), + ...childFolders.filter((row) => !pinned.has(row.directory)) + ]), + databases: Object.freeze( + snapshot.databases + .filter((row) => parentDirOf(row.directory) === directory) + .sort((a, b) => a.title.localeCompare(b.title)) + ), + notes: getBrowseNotes(snapshot, directory, pins.notes) + }) +} + +export { + createBrowseDatabase, + requestRenameBrowseDatabase, + requestCreateBrowseFolder, + requestRenameBrowseFolder, + requestDeleteBrowseDirectory, + type BrowseActionHost, + type BrowseActionResult +} from './lib/browse-actions' diff --git a/packages/app-core/src/commands.ts b/packages/app-core/src/commands.ts new file mode 100644 index 00000000..e015bbfd --- /dev/null +++ b/packages/app-core/src/commands.ts @@ -0,0 +1,33 @@ +import { isWorkspaceTransitionPending } from './lib/workspace-transition' +import { buildCommands } from './lib/commands' +import { useStore } from './store' + +export interface AppCommand { + readonly id: string + readonly title: string + readonly category: string + readonly keywords?: string + readonly shortcut?: string + readonly available: boolean +} +/** Descriptions are snapshots. Invocation always rechecks the current command. */ +export function getAppCommands(): readonly AppCommand[] { + return Object.freeze(buildCommands({ includeUnavailable: true }).map(command => Object.freeze({ + id: command.id, title: command.title, category: command.category, + keywords: command.keywords, shortcut: command.shortcut, + available: !command.when || command.when() + }))) +} +export async function runAppCommand(id: string): Promise { + if (isWorkspaceTransitionPending()) return false + const command = buildCommands({ includeUnavailable: true }).find(command => command.id === id) + if (!command || (command.when && !command.when())) return false + await command.run() + return true +} +export function showCommandPalette(): void { useStore.getState().setCommandPaletteOpen(true) } +export function showSearch(): void { useStore.getState().setSearchOpen(true) } +export function showTemplates(): void { useStore.getState().setTemplatePaletteOpen(true) } +export function showOutline(): void { + if (useStore.getState().activeNote) useStore.getState().setOutlinePaletteOpen(true) +} diff --git a/packages/app-core/src/components/ArchiveView.tsx b/packages/app-core/src/components/ArchiveView.tsx index 95d6a2d7..39cae579 100644 --- a/packages/app-core/src/components/ArchiveView.tsx +++ b/packages/app-core/src/components/ArchiveView.tsx @@ -1,10 +1,10 @@ +import { runNoteLifecycleAction } from '../lib/note-lifecycle-actions' import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ContextMenuItem } from './ContextMenu' import type { NoteMeta } from '@shared/ipc' import { isArchiveViewActive, useStore } from '../store' import { ArchiveIcon, ArrowUpRightIcon, TrashIcon } from './icons' import { CollectionViewHeader } from './CollectionViewHeader' -import { confirmMoveToTrash } from '../lib/confirm-trash' import { ContextMenu } from './ContextMenu' import { buildMoveNotePrompt, parseMoveNoteTarget } from '../lib/move-note' import { promptApp } from '../lib/prompt-requests' @@ -108,17 +108,14 @@ export function ArchiveView(): JSX.Element { const unarchiveNote = useCallback( async (note: NoteMeta) => { - await window.zen.unarchiveNote(note.path) - await refreshNotes() + await runNoteLifecycleAction(note.path, 'restore') }, [refreshNotes] ) const moveNoteToTrash = useCallback( async (note: NoteMeta) => { - if (!(await confirmMoveToTrash(note.title))) return - await window.zen.moveToTrash(note.path) - await refreshNotes() + await runNoteLifecycleAction(note.path, 'trash') }, [refreshNotes] ) @@ -233,9 +230,7 @@ export function ArchiveView(): JSX.Element { label: `Move to ${folderLabels.inbox}`, icon: , onSelect: async () => { - const meta = await window.zen.unarchiveNote(note.path) - await refreshNotes() - if (selectedPath === note.path) await selectNote(meta.path) + await runNoteLifecycleAction(note.path, 'restore') } }) items.push({ @@ -243,10 +238,7 @@ export function ArchiveView(): JSX.Element { icon: , danger: true, onSelect: async () => { - if (!(await confirmMoveToTrash(note.title))) return - await window.zen.moveToTrash(note.path) - await refreshNotes() - if (selectedPath === note.path) await selectNote(null) + await runNoteLifecycleAction(note.path, 'trash') } }) diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx index c490085a..5df5281a 100644 --- a/packages/app-core/src/components/CalendarPanel.tsx +++ b/packages/app-core/src/components/CalendarPanel.tsx @@ -1,3 +1,4 @@ +import { runNoteLifecycleAction } from '../lib/note-lifecycle-actions' /** * Right-side calendar panel — a date navigator for daily and weekly notes, * modelled on Obsidian's Calendar plugin. @@ -37,8 +38,6 @@ import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { resolveWeekStartDay } from '../lib/week-start' import { ChevronLeftIcon, ChevronRightIcon } from './icons' import { confirmApp } from '../lib/confirm-requests' -import { confirmMoveToTrash } from '../lib/confirm-trash' -import { moveNoteToTrash } from '../lib/trash-note' import { usePanelResize } from '../lib/use-panel-resize' import { PanelResizeHandle } from './PanelResizeHandle' import { ContextMenu, type ContextMenuItem } from './ContextMenu' @@ -389,10 +388,7 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { // --- Context menu -------------------------------------------------------- const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) const trashNote = useCallback(async (meta: NoteMeta) => { - if (!(await confirmMoveToTrash(meta.title))) return - await moveNoteToTrash(meta.path, { - temporarySession: useStore.getState().vault?.temporary === true - }) + await runNoteLifecycleAction(meta.path, 'trash') }, []) const openDayMenu = useCallback( (e: React.MouseEvent, day: Date, iso: string) => { diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index 037aa2b8..e9192e30 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -978,6 +978,59 @@ describe("CloudSettings", () => { expect(host.textContent).not.toContain("Everything is up to date"); }); + it.each(["manual", "background"] as const)( + "retires deleted Cloud actions after a %s sync confirms that the host removed the link (#791)", + async (trigger) => { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.listCloudVaults.mockResolvedValue([{ id: "vault-1", name: "Cloud Notes" }, { id: "vault-2", name: "Other preserved vault" }]); + mocks.getCloudVaultLink.mockResolvedValue({ + base_url: connected.account!.base_url, + vault_id: "vault-1", + vault_name: "Cloud Notes", + linked_at: "2026-08-10T12:00:00.000Z", + }); + mocks.syncCloudVault.mockResolvedValueOnce({ + cursor: 7, pulled: 0, pushed: 0, conflicts: [], bootstrap_conflicts: [], local_conflicts: [], + }).mockImplementationOnce(async () => { + // Host confirmation retires only the remote association. Settings + // must discover that result even while this panel stays mounted. + mocks.getCloudVaultLink.mockResolvedValue(null); + throw new Error("Error invoking remote method 'cloud-vault:sync': Error: This Cloud vault no longer exists. Your local notes are safe."); + }); + await act(async () => root.render(createElement(CloudSettings, { + localVaultAvailable: true, localVaultName: "Notes", + }))); + const sync = () => [...host.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Sync now", + ); + await act(async () => sync()!.click()); + expect(host.textContent).toContain("Everything is up to date"); + if (trigger === "manual") { + await act(async () => sync()!.click()); + } else { + const actual = await vi.importActual("../lib/cloud-auto-sync"); + await act(async () => { + await actual.syncCloudVaultWithStatus(mocks, "Cloud Notes").catch(() => undefined); + }); + } + + expect(host.textContent).not.toContain("Linked to Cloud Notes"); + expect(host.textContent).not.toContain("Cloud Notes"); + expect(host.textContent).not.toContain("Error invoking remote method"); + expect(host.textContent).toContain("This Cloud vault no longer exists. Your local notes are safe."); + const actions = [...host.querySelectorAll("button")].map((button) => button.textContent?.trim()); + expect(actions).not.toContain("Sync now"); + expect(actions).not.toContain("Unlink this device"); + expect(actions).not.toContain("Delete Cloud vault"); + expect(host.textContent).not.toContain("Everything is up to date"); + const open = [...host.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Open on this device"); + expect(open).toBeDefined(); + expect(open!.disabled).toBe(false); + expect(mocks.logoutCloudAccount).not.toHaveBeenCalled(); + }, + ); + // Settings that differ between devices are a question, not a silent merge. // Doing nothing keeps this device's settings, so the local choice leads. it("asks which vault settings to keep and applies the answer", async () => { diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index 0294b3f8..8735772a 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -90,13 +90,33 @@ export function CloudSettings({ const [error, setError] = useState(null); useEffect(() => { - return useCloudSyncStatusStore.subscribe((next, previous) => { + let mounted = true; + const unsubscribe = useCloudSyncStatusStore.subscribe((next, previous) => { // A saved decision updates this panel immediately, then the remaining // vault sync may finish later. Adopt that result (or a vault reset), but // keep explicit restore/manual summaries through unrelated status changes. if (next.lastSummary !== previous.lastSummary) setSummary(next.lastSummary); + if (next.phase === "unlinked" && next.error) { + void bridge.getCloudVaultLink().then((currentLink) => { + if (!mounted || currentLink !== null) return; + setLink(null); + const remainingVaults = cloudVaults.filter((vault) => vault.id !== link?.vault_id); + setCloudVaults(remainingVaults); + setSelectedVaultId((selected) => remainingVaults.some((vault) => vault.id === selected) + ? selected : (remainingVaults[0]?.id ?? "")); + setSummary(null); + setSettingsConflict(null); + setBackups([]); + setBackupSchedule(null); + setExpandedBackupId(null); + setBackupItems([]); + setRestoreResult(null); + setError(next.error); + }).catch(() => {}); + } }); - }, []); + return () => { mounted = false; unsubscribe(); }; + }, [bridge, link, cloudVaults]); const loadStatus = useCallback( async (nextStatus?: CloudAccountStatus): Promise => { diff --git a/packages/app-core/src/components/DatabaseView.tsx b/packages/app-core/src/components/DatabaseView.tsx index e398276f..d8859131 100644 --- a/packages/app-core/src/components/DatabaseView.tsx +++ b/packages/app-core/src/components/DatabaseView.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, useSyncExternalStore } from 'react' import { csvPathFromDatabaseTab, formDirFromCsvPath } from '@shared/databases' import { serializeRows } from '@shared/database-csv' import { useStore } from '../store' +import { isNoteEditingLocked, subscribeNoteEditingLocks } from '../lib/note-lifecycle-lock' import { addField, addRow, @@ -31,6 +32,9 @@ export function DatabaseView({ }): JSX.Element { const csvPath = csvPathFromDatabaseTab(tabPath) const doc = useStore((s) => (csvPath ? s.databases[csvPath] : undefined)) + const vault = useStore(s => s.vault) + const locked = useSyncExternalStore(subscribeNoteEditingLocks, () => isNoteEditingLocked(vault, csvPath)) + const deletingRows = useStore((s) => !!(csvPath && s.databasesDeletingRows[csvPath])) const loading = useStore((s) => (csvPath ? !!s.databasesLoading[csvPath] : false)) const loadDatabase = useStore((s) => s.loadDatabase) const updateDatabaseRows = useStore((s) => s.updateDatabaseRows) @@ -45,9 +49,10 @@ export function DatabaseView({ // Only `.base` databases rename by title (a legacy loose `.csv` doesn't). const canRenameTitle = !!csvPath && !!formDirFromCsvPath(csvPath) + const transitioning = useStore(s => s.workspaceTransitioning) useEffect(() => { - if (csvPath && !doc && !loading) void loadDatabase(csvPath) - }, [csvPath, doc, loading, loadDatabase]) + if (csvPath && !doc && !loading && !transitioning) void loadDatabase(csvPath) + }, [csvPath, doc, loading, loadDatabase, transitioning]) if (!csvPath) { return ( @@ -77,7 +82,7 @@ export function DatabaseView({ ] return ( -
+
{editingTitle && canRenameTitle ? ( diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index c5fc9aaa..cb48e070 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -1,3 +1,4 @@ +import { noteEditingSync, noteEditingLockExtension, refreshNoteEditingLock } from '../lib/note-lifecycle-lock' /** * Single pane of the editor split view. Each leaf in the pane-layout * tree renders an `EditorPane` — owning its own CodeMirror view, tab @@ -38,6 +39,8 @@ import { } from '@codemirror/view' import { Vim, getCM, vim } from '@replit/codemirror-vim' import type { AssetMeta, ImportedAsset, NoteComment, NoteFolder } from '@shared/ipc' +import { registerNoteEditor } from '../lib/note-editor-context' +import { noteEditorHostExtension } from '../lib/editor-host' import { history, historyKeymap, @@ -1727,9 +1730,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { initialBody.length >= LARGE_DOC_LIVE_PREVIEW_DEFER_CHARS && !s0.livePreview richMarkdownDeferredRef.current = deferInitialRichMarkdown const stateStartedAt = performance.now() + viewPathRef.current = initialPath const state = EditorState.create({ doc: initialBody, extensions: [ + noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: viewPathRef.current })), + noteEditorHostExtension(), appMarkdownSnippetExtension(), vimCompartment.of(s0.vimMode ? vim() : []), // No text input outside Vim insert mode, so a CJK input method @@ -1980,6 +1986,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }) viewRef.current = view viewPathRef.current = initialPath + registerNoteEditor(view, () => viewPathRef.current, paneId) if (initialContent && useStore.getState().activePaneId === paneId) { setEditorViewRef(view) } @@ -2097,9 +2104,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } } const dispatchStartedAt = performance.now() + viewPathRef.current = nextPath + refreshNoteEditingLock(view) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: nextBody }, annotations: [ + noteEditingSync.of(true), programmatic.of(true), skipOrderedListRenumber.of(true), // A programmatic swap (tab switch / external file sync) must never be diff --git a/packages/app-core/src/components/NoteList.tsx b/packages/app-core/src/components/NoteList.tsx index 98937074..7c955f50 100644 --- a/packages/app-core/src/components/NoteList.tsx +++ b/packages/app-core/src/components/NoteList.tsx @@ -1,3 +1,4 @@ +import { runNoteLifecycleAction, runEmptyTrash } from '../lib/note-lifecycle-actions' import { useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import { focusEditorNormalMode } from '../lib/editor-focus' @@ -14,7 +15,6 @@ import { import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { ResizeHandle } from './ResizeHandle' import { Button, IconButton } from './ui/Button' -import { confirmMoveToTrash } from '../lib/confirm-trash' import { buildMoveNotePrompt, parseMoveNoteTarget } from '../lib/move-note' import { naturalCompare } from '../lib/natural-sort' import { extractTags } from '../lib/tags' @@ -155,8 +155,7 @@ export function NoteList(): JSX.Element { return () => observer.disconnect() }, []) const emptyTrash = async (): Promise => { - await window.zen.emptyTrash() - await useStore.getState().refreshNotes() + await runEmptyTrash() } const menuItems = useMemo(() => { @@ -178,21 +177,13 @@ export function NoteList(): JSX.Element { await navigator.clipboard.writeText(`[[${n.title}]]`) } const onArchive = async (): Promise => { - if (!(await useStore.getState().confirmArchiveNotes([n.path]))) return - await window.zen.archiveNote(n.path) - await refreshNotes() - if (selectedPath === n.path) await selectNote(null) + await runNoteLifecycleAction(n.path, 'archive') } const onUnarchive = async (): Promise => { - const meta = await window.zen.unarchiveNote(n.path) - await refreshNotes() - if (selectedPath === n.path) await selectNote(meta.path) + await runNoteLifecycleAction(n.path, 'restore') } const onTrash = async (): Promise => { - if (!(await confirmMoveToTrash(n.title))) return - await window.zen.moveToTrash(n.path) - await refreshNotes() - if (selectedPath === n.path) await selectNote(null) + await runNoteLifecycleAction(n.path, 'trash') } const onMove = async (): Promise => { const target = await promptApp(buildMoveNotePrompt(n, folders)) @@ -201,14 +192,10 @@ export function NoteList(): JSX.Element { await moveNote(n.path, dest.folder, dest.subpath) } const onRestore = async (): Promise => { - const meta = await window.zen.restoreFromTrash(n.path) - await refreshNotes() - if (selectedPath === n.path) await selectNote(meta.path) + await runNoteLifecycleAction(n.path, 'restore') } const onDeleteForever = async (): Promise => { - await window.zen.deleteNote(n.path) - await refreshNotes() - if (selectedPath === n.path) await selectNote(null) + await runNoteLifecycleAction(n.path, 'delete') } const onNew = async (): Promise => { await useStore diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index 254b2399..75f04cf1 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -1,3 +1,4 @@ +import { noteEditingSync, noteEditingLockExtension, refreshNoteEditingLock } from '../lib/note-lifecycle-lock' /** * Always-visible side panel that shows a single companion note — a * "reference pane" writers and researchers can keep open while drafting @@ -208,9 +209,11 @@ export function PinnedReferencePane(): JSX.Element | null { const s0 = useStore.getState() const initialPath = s0.pinnedRefPath const initialContent = initialPath ? s0.noteContents[initialPath] ?? null : null + viewPathRef.current = initialPath const state = EditorState.create({ doc: initialContent?.body ?? '', extensions: [ + noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: viewPathRef.current })), appMarkdownSnippetExtension(), vimCompartment.of(s0.vimMode ? vim() : []), vimVisualHighlightExtension, @@ -306,9 +309,11 @@ export function PinnedReferencePane(): JSX.Element | null { const sel = view.state.selection.main const clampedAnchor = Math.min(sel.anchor, nextBody.length) const clampedHead = Math.min(sel.head, nextBody.length) + viewPathRef.current = nextPath + refreshNoteEditingLock(view) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: nextBody }, - annotations: programmatic.of(true), + annotations: [programmatic.of(true), noteEditingSync.of(true)], selection: pathChanged ? { anchor: 0 } : { anchor: clampedAnchor, head: clampedHead } }) viewPathRef.current = nextPath diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index cfa61cc2..4b70abef 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -354,6 +354,8 @@ function formatUpdatePhaseLabel(phase: AppUpdateState["phase"]): string { return "Downloading"; case "downloaded": return "Ready to install"; + case "installing": + return "Installing"; case "error": return "Update error"; case "idle": @@ -369,6 +371,7 @@ function updatePhaseBadgeClass(phase: AppUpdateState["phase"]): string { return "border-accent/30 bg-accent/10 text-accent"; case "checking": case "downloading": + case "installing": return "border-paper-300/70 bg-paper-100/85 text-ink-700"; case "error": return "border-red-400/25 bg-red-500/10 text-red-700"; @@ -5077,12 +5080,14 @@ export function SettingsModal(): JSX.Element { onClick={triggerUpdateCheck} disabled={ appUpdateState?.phase === "checking" || - appUpdateState?.phase === "downloading" + appUpdateState?.phase === "downloading" || + appUpdateState?.phase === "installing" } className={[ "rounded-xl border px-3.5 py-2 text-xs font-medium transition-colors", appUpdateState?.phase === "checking" || - appUpdateState?.phase === "downloading" + appUpdateState?.phase === "downloading" || + appUpdateState?.phase === "installing" ? "cursor-not-allowed border-paper-300/60 bg-paper-100/45 text-ink-400" : "border-paper-300/70 bg-paper-100/80 text-ink-800 hover:bg-paper-200", ].join(" ")} @@ -7573,11 +7578,11 @@ function CliSettings(): JSX.Element { void refresh(); }, [refresh]); - const onInstall = async (): Promise => { + const onInstall = async (repairToken?: string): Promise => { setBusy(true); setError(null); try { - await window.zen.cliInstall(); + await window.zen.cliInstall(repairToken ? { repairToken } : undefined); await refresh(); } catch (err) { setError((err as Error).message); @@ -7635,11 +7640,13 @@ function CliSettings(): JSX.Element { const installed = status.installedAt != null; const ours = status.installedByThisApp; - const chip = installed - ? ours - ? { label: "Installed", tone: "ok" as const } - : { label: "External install", tone: "warn" as const } - : { label: "Not installed", tone: "off" as const }; + const chip = status.repair + ? { label: "Needs repair", tone: "warn" as const } + : installed + ? ours + ? { label: "Installed", tone: "ok" as const } + : { label: "External install", tone: "warn" as const } + : { label: "Not installed", tone: "off" as const }; const isUnavailable = !status.available; @@ -7647,14 +7654,18 @@ function CliSettings(): JSX.Element {
- zen + zn
- {installed && ours - ? `Active. Run \`zn --help\` from any terminal.` - : installed && !ours - ? `An unmanaged \`zen\` already exists at this path. Remove it before installing if you want ZenNotes to take over.` - : status.requiresSudo - ? `Symlinks ${status.defaultTarget} to ZenNotes' bundled wrapper. macOS will prompt for admin once because no user-writable directory was found on your PATH.` - : `Symlinks ${status.defaultTarget} to ZenNotes' bundled wrapper.`} + {status.repair + ? "This shortcut points to an app location that no longer exists. Review the replacement below, then repair it in place." + : installed && ours + ? status.runtime === "go" + ? `Active. Run \`zn tui\` to open the terminal app, or \`zn --help\` for commands.` + : `Active. Run \`zn --help\` from any terminal.` + : installed && !ours + ? `This installation is managed outside ZenNotes. Keep using its installer or package manager for updates.` + : status.requiresSudo + ? `Installs zn at ${status.defaultTarget}. Administrator access is needed because no user-writable directory was found on your PATH.` + : `Installs zn at ${status.defaultTarget}.`}
+ {status.repair && ( +
+
Repair this shortcut
+
+ Previous target:{" "} + + {status.repair.oldTarget} + +
+
+ New target:{" "} + + {status.repair.newTarget} + +
+
+ The previous target will be saved in{" "} + + {status.repair.backupPath} + + . +
+
+ )} + {status.runtimeVersion && ours && ( +
+ Installed version: {status.runtimeVersion} +
+ )} + {status.runtimeError && ( +
+ {status.runtimeError} + {installed ? " Your existing CLI remains available." : ""} +
+ )} {status.reason && (
{status.reason} @@ -7686,7 +7736,7 @@ function CliSettings(): JSX.Element {
After install, run this once so your shell can find{" "} - zen: + zn:
@@ -7704,7 +7754,25 @@ function CliSettings(): JSX.Element { )}
- {installed ? ( + {status.repair && ( + + )} + {installed && ours && status.runtimeError && ( + + )} + {installed && !status.repair ? ( - ) : ( + ) : !installed ? ( - )} + ) : null}
+ {error && ( +
+ Something went wrong: {error} +
+ )}
Path @@ -7751,7 +7824,7 @@ function CliSettings(): JSX.Element {
@@ -7761,21 +7834,17 @@ function CliSettings(): JSX.Element { settingId="cli-quick-reference" >
-
zen list --tag idea
-
zen read "inbox/Project.md"
-
zen read --path "hellointerview/system design.md"
+ {status.runtime === "go" &&
zn tui
} +
zn list --tag idea
+
zn read "inbox/Project.md"
+
zn read --path "hellointerview/system design.md"
echo "hello" | zn capture
-
zen append daily.md --body "- talked to alice"
-
zen search "deadline" --json | jq .
-
zen mcp # used by Claude Code/Desktop/Codex
+
zn append daily.md --body "- talked to alice"
+
zn search "deadline" --json | jq .
+
zn mcp # used by Claude Code/Desktop/Codex
- {error && ( - - Something went wrong: {error} - - )}
); } diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 7128af26..364426fb 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { runNoteLifecycleAction, runNoteBatchAction, runEmptyTrash } from "../lib/note-lifecycle-actions"; import { createContext, memo, @@ -23,8 +24,6 @@ import { useStore, } from "../store"; import { Button } from "./ui/Button"; -import { confirmMoveToTrash } from "../lib/confirm-trash"; -import { moveNoteToTrash } from "../lib/trash-note"; import { buildMoveNotePrompt, parseMoveNoteTarget } from "../lib/move-note"; import { buildTagTree, extractTags, flattenTagTree } from "../lib/tags"; import { isTypstPreamblePath, resolveTypstPreambleFolder } from "../lib/typst-preamble"; @@ -1692,19 +1691,7 @@ export function Sidebar(): JSX.Element { items.push({ label: `Move ${liveNotes.length} note${liveNotes.length === 1 ? "" : "s"}…`, onSelect: async () => { - const target = await promptApp( - buildMoveNotePrompt( - { title: `${liveNotes.length} notes`, path: liveNotes[0]!.path }, - allFolders, - ), - ); - if (!target) return; - const dest = parseMoveNoteTarget(target); - for (const note of liveNotes) { - await window.zen.moveNote(note.path, dest.folder, dest.subpath); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(liveNotes.map(note => note.path), "move")) clearSelection(); }, }); } @@ -1714,13 +1701,7 @@ export function Sidebar(): JSX.Element { label: `Move ${archivableNotes.length} note${archivableNotes.length === 1 ? "" : "s"} to ${folderLabels.archive}`, icon: , onSelect: async () => { - const paths = archivableNotes.map((note) => note.path); - if (!(await useStore.getState().confirmArchiveNotes(paths))) return; - for (const note of archivableNotes) { - await window.zen.archiveNote(note.path); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(archivableNotes.map(note => note.path), "archive")) clearSelection(); }, }); } @@ -1730,11 +1711,7 @@ export function Sidebar(): JSX.Element { label: `Move ${archivedNotes.length} archived note${archivedNotes.length === 1 ? "" : "s"} to ${folderLabels.inbox}`, icon: , onSelect: async () => { - for (const note of archivedNotes) { - await window.zen.unarchiveNote(note.path); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(archivedNotes.map(note => note.path), "restore")) clearSelection(); }, }); } @@ -1745,20 +1722,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, onSelect: async () => { - const ok = await confirmApp({ - title: `Move ${liveNotes.length} note${liveNotes.length === 1 ? "" : "s"} to ${folderLabels.trash}?`, - description: "You can restore them from Trash later.", - confirmLabel: `Move to ${folderLabels.trash}`, - danger: true, - }); - if (!ok) return; - for (const note of liveNotes) { - await moveNoteToTrash(note.path, { - temporarySession: vault?.temporary === true, - }); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(liveNotes.map(note => note.path), "trash")) clearSelection(); }, }); } @@ -1768,11 +1732,7 @@ export function Sidebar(): JSX.Element { label: `Restore ${trashedNotes.length} note${trashedNotes.length === 1 ? "" : "s"}`, icon: , onSelect: async () => { - for (const note of trashedNotes) { - await window.zen.restoreFromTrash(note.path); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(trashedNotes.map(note => note.path), "restore")) clearSelection(); }, }); items.push({ @@ -1780,18 +1740,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, onSelect: async () => { - const ok = await confirmApp({ - title: `Delete ${trashedNotes.length} note${trashedNotes.length === 1 ? "" : "s"} permanently?`, - description: "This cannot be undone.", - confirmLabel: "Delete permanently", - danger: true, - }); - if (!ok) return; - for (const note of trashedNotes) { - await window.zen.deleteNote(note.path); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(trashedNotes.map(note => note.path), "delete")) clearSelection(); }, }); } @@ -1905,18 +1854,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, disabled: trashCount === 0, - onSelect: async () => { - const ok = await confirmApp({ - title: `Delete ${trashCount} trashed note${trashCount === 1 ? "" : "s"} permanently?`, - description: "This cannot be undone.", - confirmLabel: `Empty ${folderLabels.trash}`, - danger: true, - }); - if (!ok) return; - await window.zen.emptyTrash(); - await refreshNotes(); - if (selectedPath?.startsWith("trash/")) await selectNote(null); - }, + onSelect: runEmptyTrash, }, { kind: "separator" }, ...iconItems, @@ -2435,10 +2373,7 @@ export function Sidebar(): JSX.Element { label: folderLabels.archive, icon: , onSelect: async () => { - if (!(await useStore.getState().confirmArchiveNotes([n.path]))) return; - await window.zen.archiveNote(n.path); - await refreshNotes(); - if (selectedPath === n.path) await selectNote(null); + await runNoteLifecycleAction(n.path, "archive"); }, }); items.push({ @@ -2446,15 +2381,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, onSelect: async () => { - if (!(await confirmMoveToTrash(n.title))) return; - if ( - !(await moveNoteToTrash(n.path, { - temporarySession: vault?.temporary === true, - })) - ) - return; - await refreshNotes(); - if (selectedPath === n.path) await selectNote(null); + await runNoteLifecycleAction(n.path, "trash"); }, }); } else if (n.folder === "archive") { @@ -2462,9 +2389,7 @@ export function Sidebar(): JSX.Element { label: `Move to ${folderLabels.inbox}`, icon: , onSelect: async () => { - const meta = await window.zen.unarchiveNote(n.path); - await refreshNotes(); - if (selectedPath === n.path) await selectNote(meta.path); + await runNoteLifecycleAction(n.path, "restore"); }, }); items.push({ @@ -2472,15 +2397,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, onSelect: async () => { - if (!(await confirmMoveToTrash(n.title))) return; - if ( - !(await moveNoteToTrash(n.path, { - temporarySession: vault?.temporary === true, - })) - ) - return; - await refreshNotes(); - if (selectedPath === n.path) await selectNote(null); + await runNoteLifecycleAction(n.path, "trash"); }, }); } else { @@ -2488,9 +2405,7 @@ export function Sidebar(): JSX.Element { label: "Restore", icon: , onSelect: async () => { - const meta = await window.zen.restoreFromTrash(n.path); - await refreshNotes(); - if (selectedPath === n.path) await selectNote(meta.path); + await runNoteLifecycleAction(n.path, "restore"); }, }); items.push({ @@ -2498,9 +2413,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, onSelect: async () => { - await window.zen.deleteNote(n.path); - await refreshNotes(); - if (selectedPath === n.path) await selectNote(null); + await runNoteLifecycleAction(n.path, "delete"); }, }); } diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index 37d9f3d2..249a21f9 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -1,3 +1,4 @@ +import { dropMutationsFor } from '../lib/task-column-mutations' /** * Kanban view for the Tasks tab. * @@ -79,79 +80,7 @@ function columnAccent(id: string): string | null { return COLUMN_ACCENTS[hash % COLUMN_ACCENTS.length] } -/** Map a (groupBy, columnId) drop target to the task-line mutations - * that should land. Returns `null` when the drop has no defined - * semantics (e.g. when group-by is 'folder'). Returns `[]` when the - * task is already in the target column — caller can short-circuit. */ -export function dropMutationsFor( - groupBy: KanbanGroupBy, - columnId: string, - task: VaultTask, - today: Date -): TaskMutation[] | null { - if (groupBy === 'status') { - const todayIso = toIsoDateLocal(today) - switch (columnId) { - case 'today': - // "Live" columns — make sure neither @waiting, [x] nor [/] keep the - // task glued to a different bucket. - return [ - { kind: 'set-checked', checked: false }, - { kind: 'set-waiting', waiting: false }, - { kind: 'set-in-progress', inProgress: false }, - { kind: 'set-due', due: todayIso } - ] - case 'upcoming': { - const tomorrow = new Date(today) - tomorrow.setDate(tomorrow.getDate() + 1) - return [ - { kind: 'set-checked', checked: false }, - { kind: 'set-waiting', waiting: false }, - { kind: 'set-in-progress', inProgress: false }, - { - kind: 'set-due', - due: task.due && task.due > todayIso ? task.due : toIsoDateLocal(tomorrow) - } - ] - } - case IN_PROGRESS_COLUMN_ID: - // Started work: `[/]`. The due date is left alone, so a card dragged - // back to Today or Upcoming keeps the date it had. - return [ - { kind: 'set-checked', checked: false }, - { kind: 'set-waiting', waiting: false }, - { kind: 'set-in-progress', inProgress: true } - ] - case 'waiting': - // `[/]` survives underneath on purpose: clearing the wait returns the - // card to In progress, where it came from. - return [ - { kind: 'set-checked', checked: false }, - { kind: 'set-waiting', waiting: true } - ] - case 'done': - return [{ kind: 'set-checked', checked: true }] - default: - return null - } - } - if (groupBy === 'priority') { - if (columnId === 'high') return [{ kind: 'set-priority', priority: 'high' }] - if (columnId === 'med') return [{ kind: 'set-priority', priority: 'med' }] - if (columnId === 'low') return [{ kind: 'set-priority', priority: 'low' }] - if (columnId === 'none') return [{ kind: 'set-priority', priority: null }] - return null - } - if (groupBy.startsWith('field:')) { - // Drop sets the `@:` token; the No- column clears it. - const key = groupBy.slice('field:'.length) - return [{ kind: 'set-field', key, value: columnId === NO_VALUE_COLUMN_ID ? null : columnId }] - } - // Folder grouping is read-only — moving the task across folders - // means moving the source note, which the user does explicitly via - // the sidebar. - return null -} +export { dropMutationsFor } from '../lib/task-column-mutations' export interface Column { id: string diff --git a/packages/app-core/src/components/TrashView.tsx b/packages/app-core/src/components/TrashView.tsx index 0002056a..9d132b99 100644 --- a/packages/app-core/src/components/TrashView.tsx +++ b/packages/app-core/src/components/TrashView.tsx @@ -1,3 +1,4 @@ +import { runNoteLifecycleAction, runEmptyTrash } from '../lib/note-lifecycle-actions' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { DeletedAsset, NoteMeta } from '@shared/ipc' import { isTrashViewActive, useStore } from '../store' @@ -6,7 +7,6 @@ import { CollectionViewHeader } from './CollectionViewHeader' import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' import { getSystemFolderLabel } from '../lib/system-folder-labels' import { confirmApp } from '../lib/confirm-requests' -import { confirmDeletePermanently } from '../lib/confirm-trash' import { isAppOverlayOpen } from '../lib/overlay-open' function formatDate(ms: number): string { @@ -93,33 +93,19 @@ export function TrashView(): JSX.Element { const restoreNote = useCallback( async (note: NoteMeta) => { - await window.zen.restoreFromTrash(note.path) - await refreshNotes() + await runNoteLifecycleAction(note.path, 'restore') }, [refreshNotes] ) const deleteNoteForever = useCallback( async (note: NoteMeta) => { - if (!(await confirmDeletePermanently(note.title))) return - await window.zen.deleteNote(note.path) - await refreshNotes() + await runNoteLifecycleAction(note.path, 'delete') }, [refreshNotes] ) - const emptyTrash = useCallback(async () => { - if (trashed.length === 0) return - const ok = await confirmApp({ - title: `Delete ${trashed.length} trashed note${trashed.length === 1 ? '' : 's'} permanently?`, - description: 'This cannot be undone.', - confirmLabel: 'Empty trash', - danger: true - }) - if (!ok) return - await window.zen.emptyTrash() - await refreshNotes() - }, [refreshNotes, trashed.length]) + const emptyTrash = useCallback(runEmptyTrash, []) // Deleted assets live in a separate on-disk store (.zennotes/deleted-assets), // surfaced here so they're recoverable like notes rather than lost after the diff --git a/packages/app-core/src/database-row-actions.test.ts b/packages/app-core/src/database-row-actions.test.ts new file mode 100644 index 00000000..075ce199 --- /dev/null +++ b/packages/app-core/src/database-row-actions.test.ts @@ -0,0 +1,193 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DatabaseDoc } from '@shared/databases' + +const CSV = 'Projects.base/data.csv' +const PAGE = 'Projects.base/pages/One.md' +const TWO = 'Projects.base/pages/Two.md' +const body = '# One\n\nKeep café 日本語. \n' +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} +beforeEach(() => { vi.resetModules(); localStorage.clear() }) +async function setup() { + const files = new Map([[PAGE, body], [TWO, '# Two\n\nSecond.\n']]) + const metadata = (path: string) => ({ path, title: path.split('/').pop()!, folder: path.startsWith('trash/') ? 'trash' as const : 'inbox' as const, + siblingOrder: 0, createdAt: 1, updatedAt: 1, size: 0, tags: [], wikilinks: [], assetEmbeds: [], hasAttachments: false, excerpt: '' }) + const doc: DatabaseDoc = { + version: 1, path: CSV, title: 'Projects', idFieldId: 'id', activeViewId: 'table', views: [], + fields: [{ id: 'id', name: 'ID', type: 'text' }, { id: 'name', name: 'Name', type: 'text' }, { id: 'status', name: 'Status', type: 'text' }], + rows: [{ id: 'one', cells: { id: 'one', name: 'One', status: 'Pending' } }, { id: 'two', cells: { id: 'two', name: 'Two', status: 'Open' } }], + pages: { one: PAGE, two: TWO }, pageHasContent: { one: true, two: true } + } + let diskDoc = structuredClone(doc) + const bridge = { + getCapabilities: () => ({}), listNotes: async () => [...files.keys()].map(metadata), listFolders: async () => [], + scanTasks: async () => [], scanTasksForPath: async () => [], hasAssetsDir: async () => false, + getRemoteWorkspaceInfo: async () => null, setVaultSettings: async (s: unknown) => s, + readNote: vi.fn(async (path: string) => { + if (!files.has(path)) throw new Error('Missing page') + return { ...metadata(path), body: files.get(path)! } + }), + writeNote: vi.fn(async (path: string, text: string) => { files.set(path, text); return metadata(path) }), + writeDatabaseRows: vi.fn(async (_path: string, rows: DatabaseDoc['rows']) => { diskDoc.rows = structuredClone(rows) }), + writeDatabaseSchema: vi.fn(async (_path: string, schema: object, rows: DatabaseDoc['rows']) => { + diskDoc = { ...diskDoc, ...structuredClone(schema), rows: structuredClone(rows) } + }), + moveToTrash: vi.fn(async (path: string) => { + const next = `trash/${path.split('/').pop()}` + files.set(next, files.get(path)!); files.delete(path); return metadata(next) + }) + } + Object.defineProperty(window, 'zen', { configurable: true, value: bridge }) + const { useStore } = await import('./store') + const prompts = await import('./lib/confirm-requests') + useStore.setState({ vault: { root: '/test', name: 'Test' }, databases: { [CSV]: doc }, notes: [...files.keys()].map(metadata), noteContents: {}, noteDirty: {} }) + const start = (ids = ['one'], trash = true) => { + const running = useStore.getState().deleteDatabaseRows(CSV, ids) + const request = prompts.getConfirmRequest() + if (request) prompts.settleConfirmRequest(request, trash) + return running + } + return { useStore, bridge, files, doc, metadata, prompts, start, disk: () => diskDoc } +} + +describe('database row lifecycle', () => { + it('materializes the latest properties and exact closed-page body before detaching', async () => { + const s = await setup() + const pending = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) + s.useStore.getState().updateDatabaseRows(CSV, { ...s.doc, rows: s.doc.rows.map(r => r.id === 'one' ? { ...r, cells: { ...r.cells, status: 'Ready' } } : r) }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, false) + await pending + expect(s.files.get(PAGE)).toBe(`---\nStatus: Ready\n---\n${body}`) + expect(s.disk().rows.map(r => r.id)).toEqual(['two']) + expect(s.disk().pages).toEqual({ two: TWO }) + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + }) + it('saves the dirty open page and prevents edits until its move completes', async () => { + const s = await setup(), gate = deferred() + s.useStore.setState({ noteContents: { [PAGE]: { ...s.metadata(PAGE), body: body + 'Unsaved\n' } }, noteDirty: { [PAGE]: true } }) + const move = s.bridge.moveToTrash.getMockImplementation()! + s.bridge.moveToTrash.mockImplementation(async p => { await gate.promise; return move(p) }) + const pending = s.start() + await vi.waitFor(() => expect(s.bridge.moveToTrash).toHaveBeenCalled()) + s.useStore.getState().updateNoteBody(PAGE, 'must not overwrite') + s.useStore.getState().updateDatabaseRows(CSV, s.doc) + expect(s.useStore.getState().databases[CSV].rows.map(r => r.id)).toEqual(['two']) + gate.resolve(); await pending + expect(s.files.get('trash/One.md')).toBe(`---\nStatus: Pending\n---\n${body}Unsaved\n`) + expect(s.useStore.getState().databasesDeletingRows[CSV]).toBe(false) + }) + it('leaves rows intact and dispatches no trash when a page cannot be saved', async () => { + const s = await setup() + s.bridge.writeNote.mockRejectedValue(new Error('disk full')) + await s.start() + expect(s.useStore.getState().databases[CSV]).toEqual(s.doc) + expect(s.bridge.writeDatabaseSchema).not.toHaveBeenCalled() + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + }) + it('restores recoverable rows and dispatches no trash when the database commit fails', async () => { + const s = await setup() + s.bridge.writeDatabaseSchema.mockRejectedValueOnce(new Error('schema unavailable')) + await s.start() + expect(s.useStore.getState().databases[CSV]).toEqual(s.doc) + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + await s.useStore.getState().flushDirtyNotes() + expect(s.disk().rows).toEqual(s.doc.rows) + expect(s.disk().pages).toEqual(s.doc.pages) + }) + it('retains standalone remaining pages if a later move fails after the row commit', async () => { + const s = await setup(), move = s.bridge.moveToTrash.getMockImplementation()! + s.bridge.moveToTrash.mockImplementation(async p => { if (p === TWO) throw new Error('locked'); return move(p) }) + await s.start(['one', 'two', 'one']) + expect(s.disk().rows).toEqual([]) + expect(s.disk().pages).toEqual({}) + expect(s.files.has(PAGE)).toBe(false) + expect(s.files.get(TWO)).toBe('---\nStatus: Open\n---\n# Two\n\nSecond.\n') + expect(s.bridge.moveToTrash).toHaveBeenCalledTimes(2) + }) + it('does not change a page shared by a surviving row or a foreign mapping', async () => { + const s = await setup() + s.useStore.setState({ databases: { [CSV]: { ...s.doc, pages: { one: TWO, two: TWO } } } }) + await s.start() + expect(s.bridge.writeNote).not.toHaveBeenCalled() + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + s.useStore.setState({ databases: { [CSV]: { ...s.doc, pages: { one: 'inbox/Foreign.md' } } } }) + await s.start() + expect(s.bridge.readNote).not.toHaveBeenCalled() + expect(s.bridge.writeNote).not.toHaveBeenCalled() + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + }) + it('abandons a changed mapping after the confirmation', async () => { + const s = await setup() + const pending = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) + s.useStore.setState({ databases: { [CSV]: { ...s.doc, pages: { one: TWO } } } }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, true) + await pending + expect(s.bridge.writeNote).not.toHaveBeenCalled() + expect(s.bridge.writeDatabaseSchema).not.toHaveBeenCalled() + }) + it('abandons a vault switch during confirmation', async () => { + const s = await setup() + const pending = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) + s.useStore.setState({ vault: { root: '/other', name: 'Other' } }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, true) + await pending + expect(s.bridge.readNote).not.toHaveBeenCalled() + expect(s.bridge.writeDatabaseSchema).not.toHaveBeenCalled() + }) + it('waits for the entire page batch before a vault-switch save finishes', async () => { + const s = await setup(), gate = deferred(), move = s.bridge.moveToTrash.getMockImplementation()! + s.bridge.moveToTrash.mockImplementation(async p => { await gate.promise; return move(p) }) + const pending = s.start(['one', 'two']) + await vi.waitFor(() => expect(s.bridge.moveToTrash).toHaveBeenCalled()) + let flushed = false + const flush = s.useStore.getState().flushDirtyNotes().then(() => { flushed = true }) + await new Promise(resolve => setTimeout(resolve, 5)) + expect(flushed).toBe(false) + gate.resolve(); await pending; await flush + expect(s.files.has(TWO)).toBe(false) + expect(flushed).toBe(true) + }) + it('does not replace the first row confirmation when deletion is dispatched twice', async () => { + const s = await setup() + const first = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) + const request = s.prompts.getConfirmRequest()! + await s.useStore.getState().deleteDatabaseRows(CSV, ['two']) + expect(s.prompts.getConfirmRequest()).toBe(request) + s.prompts.settleConfirmRequest(request, false) + await first + expect(s.disk().rows.map(row => row.id)).toEqual(['two']) + }) + it('does not trash a page when its selected row disappears during confirmation', async () => { + const s = await setup() + const pending = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) + s.useStore.setState({ databases: { [CSV]: { ...s.doc, rows: s.doc.rows.filter(row => row.id !== 'one') } } }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, true) + await pending + expect(s.bridge.writeNote).not.toHaveBeenCalled() + expect(s.bridge.writeDatabaseSchema).not.toHaveBeenCalled() + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + }) + it('freezes remapped Trash database edits until an Empty Trash failure releases them', async () => { + const s = await setup(), gate = deferred(), csv = 'Bin/Projects.base/data.csv' + const emptyTrash = vi.fn(async () => { await gate.promise; throw new Error('denied') }) + Object.assign(s.bridge, { emptyTrash }) + s.useStore.setState({ databases: { [csv]: { ...s.doc, path: csv } }, + vaultSettings: { ...s.useStore.getState().vaultSettings, systemFolderPaths: { trash: 'Bin' } } }) + const pending = s.useStore.getState().emptyTrash() + const failed = expect(pending).rejects.toThrow('denied') + await vi.waitFor(() => expect(emptyTrash).toHaveBeenCalled()) + const original = s.useStore.getState().databases[csv] + s.useStore.getState().updateDatabaseRows(csv, { ...original, rows: [] }) + s.useStore.getState().updateDatabaseSchema(csv, { ...original, pages: {} }) + expect(s.useStore.getState().databases[csv]).toBe(original) + gate.resolve(); await failed + s.useStore.getState().updateDatabaseRows(csv, { ...original, rows: [] }) + expect(s.useStore.getState().databases[csv].rows).toEqual([]) + await s.useStore.getState().flushDirtyNotes() + }) + +}) diff --git a/packages/app-core/src/dialogs.ts b/packages/app-core/src/dialogs.ts new file mode 100644 index 00000000..fcacaf04 --- /dev/null +++ b/packages/app-core/src/dialogs.ts @@ -0,0 +1,16 @@ +import { promptApp, getPromptRequest } from './lib/prompt-requests' +import { confirmApp, getConfirmRequest } from './lib/confirm-requests' +import type { PromptOptions } from './components/PromptModal' +import type { ConfirmOptions } from './components/ConfirmModal' + +export type { PromptOptions, PromptSuggestion } from './components/PromptModal' +export type { ConfirmOptions } from './components/ConfirmModal' +/** A second host dialog is cancelled instead of replacing an unresolved request. */ +export function prompt(options: PromptOptions): Promise { + if (getPromptRequest() || getConfirmRequest()) return Promise.resolve(null) + return promptApp({ ...options, suggestions: options.suggestions?.map(suggestion => ({ ...suggestion })) }) +} +export function confirm(options: ConfirmOptions): Promise { + if (getPromptRequest() || getConfirmRequest()) return Promise.resolve(false) + return confirmApp({ ...options }) +} diff --git a/packages/app-core/src/editor-commands.test.ts b/packages/app-core/src/editor-commands.test.ts new file mode 100644 index 00000000..ec784239 --- /dev/null +++ b/packages/app-core/src/editor-commands.test.ts @@ -0,0 +1,263 @@ +// @vitest-environment jsdom + +import { history } from '@codemirror/commands' +import { search } from '@codemirror/search' +import { EditorSelection, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { EditorCommand } from './editor' + +const views: EditorView[] = [] + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { + configurable: true, + value: { getCapabilities: () => ({}) } + }) +}) + +afterEach(() => { + for (const view of views.splice(0)) view.destroy() + document.body.replaceChildren() +}) + +async function setup(body = 'hello', anchor = 0, head = body.length, readOnly = false) { + const { useStore } = await import('./store') + const api = await import('./editor') + const { registerNoteEditor } = await import('./lib/note-editor-context') + let path = 'one.md' + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: body, + selection: EditorSelection.single(anchor, head), + extensions: [ + history(), + search({ top: true }), + EditorState.readOnly.of(readOnly), + EditorState.allowMultipleSelections.of(true) + ] + }) + }) + views.push(view) + registerNoteEditor(view, () => path, useStore.getState().activePaneId) + useStore.setState({ + vault: { root: '/test-vault', name: 'Test' }, + selectedPath: path, + editorViewRef: view, + activeNote: { path, body } as NonNullable['activeNote']> + }) + return { + ...api, + useStore, + view, + setViewPath: (next: string) => { + path = next + } + } +} + +describe('public editor commands', () => { + for (const [command, marker] of [ + ['toggle-bold', '**'], + ['toggle-italic', '*'], + ['toggle-strikethrough', '~~'], + ['toggle-highlight', '=='], + ['toggle-inline-code', '`'] + ] as const) { + it(`${command} wraps and unwraps the selection`, async () => { + const s = await setup() + expect(s.runEditorCommand(command)).toBe(true) + expect(s.view.state.doc.toString()).toBe(`${marker}hello${marker}`) + expect( + s.view.state.sliceDoc(s.view.state.selection.main.from, s.view.state.selection.main.to) + ).toBe('hello') + expect(s.view.hasFocus).toBe(true) + expect(s.runEditorCommand(command)).toBe(true) + expect(s.view.state.doc.toString()).toBe('hello') + }) + } + + it('inserts an empty inline pair and exits formatting after text is entered', async () => { + const s = await setup('', 0, 0) + s.runEditorCommand('toggle-bold') + expect(s.view.state.doc.toString()).toBe('****') + expect(s.view.state.selection.main.head).toBe(2) + s.view.dispatch({ changes: { from: 2, insert: 'word' }, selection: { anchor: 6 } }) + s.runEditorCommand('toggle-bold') + expect(s.view.state.doc.toString()).toBe('**word**') + expect(s.view.state.selection.main.head).toBe(8) + }) + + for (const [command, expected, caret] of [ + ['insert-link', '[hello]()', 8], + ['insert-wikilink', '[[]]', 2], + ['insert-tag', '#', 1] + ] as const) { + it(`${command} preserves the mobile snippet and caret behavior`, async () => { + const s = await setup() + expect(s.runEditorCommand(command)).toBe(true) + expect(s.view.state.doc.toString()).toBe(expected) + expect(s.view.state.selection.main.head).toBe(caret) + expect(s.view.state.selection.main.empty).toBe(true) + }) + } + + for (const [command, marker] of [ + ['set-bullet-list', '- '], + ['set-task-list', '- [ ] '], + ['cycle-heading', '# '] + ] as const) { + it(`${command} starts a block on an indented empty line`, async () => { + const s = await setup(' ', 1, 1) + s.runEditorCommand(command) + expect(s.view.state.doc.toString()).toBe(' ' + marker) + expect(s.view.state.selection.main.head).toBe(2 + marker.length) + }) + } + + it('replaces existing block markers and preserves blank lines in a selection', async () => { + const s = await setup('# First\n\n> Second') + s.runEditorCommand('set-task-list') + expect(s.view.state.doc.toString()).toBe('- [ ] First\n\n- [ ] Second') + }) + + it('cycles headings through levels one, two, three, then paragraph', async () => { + const s = await setup('Title', 0, 0) + for (const expected of ['# Title', '## Title', '### Title', 'Title']) { + s.runEditorCommand('cycle-heading') + expect(s.view.state.doc.toString()).toBe(expected) + } + const deep = await setup('##### Title', 0, 0) + deep.runEditorCommand('cycle-heading') + expect(deep.view.state.doc.toString()).toBe('Title') + const indented = await setup(' ## Title', 0, 0) + indented.runEditorCommand('cycle-heading') + expect(indented.view.state.doc.toString()).toBe(' # Title') + }) + + it('indents and outdents the selected lines using the editor settings', async () => { + const s = await setup('one\ntwo') + s.runEditorCommand('indent') + expect(s.view.state.doc.toString()).toBe(' one\n two') + s.runEditorCommand('outdent') + expect(s.view.state.doc.toString()).toBe('one\ntwo') + }) + + it('uses the normal editor undo and redo history', async () => { + const s = await setup() + expect(s.runEditorCommand('undo')).toBe(false) + expect(s.view.hasFocus).toBe(true) + s.runEditorCommand('toggle-bold') + expect(s.runEditorCommand('undo')).toBe(true) + expect(s.view.state.doc.toString()).toBe('hello') + expect(s.runEditorCommand('redo')).toBe(true) + expect(s.view.state.doc.toString()).toBe('**hello**') + }) + + it('seeds Find from the selection and focuses its existing field on reopening', async () => { + const s = await setup() + expect(s.runEditorCommand('open-search')).toBe(true) + const field = document.querySelector('.cm-search [main-field]')! + expect(field.value).toBe('hello') + expect([field.selectionStart, field.selectionEnd]).toEqual([0, 5]) + // jsdom's input.select() does not focus on initial mount. The browser smoke + // covers that native behavior; reopening explicitly focuses the same field. + s.view.focus() + expect(s.runEditorCommand('open-search')).toBe(true) + expect(document.activeElement?.closest('.cm-search')).not.toBeNull() + expect(s.view.hasFocus).toBe(false) + expect(s.runEditorCommand('close-search')).toBe(true) + expect(document.querySelector('.cm-search')).toBeNull() + expect(s.view.hasFocus).toBe(true) + expect(s.runEditorCommand('close-search')).toBe(false) + }) + + for (const change of [ + 'vault', + 'note', + 'virtual note', + 'content', + 'view', + 'pane', + 'registered path', + 'destroyed' + ] as const) { + it(`ignores commands when the active ${change} is unavailable or transitioning`, async () => { + const s = await setup() + if (change === 'vault') s.useStore.setState({ vault: null }) + if (change === 'note') s.useStore.setState({ selectedPath: 'two.md' }) + if (change === 'virtual note') { + s.setViewPath('zen://tasks') + s.useStore.setState({ + selectedPath: 'zen://tasks', + activeNote: { ...s.useStore.getState().activeNote!, path: 'zen://tasks' } + }) + } + if (change === 'content') s.useStore.setState({ activeNote: null }) + if (change === 'view') s.useStore.setState({ editorViewRef: null }) + if (change === 'pane') s.useStore.setState({ activePaneId: 'other-pane' }) + if (change === 'registered path') s.setViewPath('two.md') + if (change === 'destroyed') s.view.destroy() + expect(s.runEditorCommand('toggle-bold')).toBe(false) + expect(s.runEditorCommand('open-search')).toBe(false) + expect(s.hasEditorSelection()).toBe(false) + expect(s.view.state.doc.toString()).toBe('hello') + }) + } + + it('allows Find and selection inspection in a read-only editor but rejects edits', async () => { + const s = await setup('hello', 0, 5, true) + expect(s.runEditorCommand('toggle-bold')).toBe(false) + expect(s.hasEditorSelection()).toBe(true) + expect(s.runEditorCommand('open-search')).toBe(true) + expect(s.view.state.doc.toString()).toBe('hello') + }) + + it('reports selections without returning mutable editor state', async () => { + const s = await setup() + expect(s.hasEditorSelection()).toBe(true) + s.view.dispatch({ selection: { anchor: 2 } }) + expect(s.hasEditorSelection()).toBe(false) + s.view.dispatch({ + selection: EditorSelection.create([EditorSelection.range(0, 1), EditorSelection.cursor(3)], 1) + }) + expect(s.hasEditorSelection()).toBe(true) + }) + + it('passes multiple selections through to inline formatting and links', async () => { + const s = await setup('one two') + s.view.dispatch({ + selection: EditorSelection.create([EditorSelection.range(0, 3), EditorSelection.range(4, 7)]) + }) + s.runEditorCommand('toggle-bold') + expect(s.view.state.doc.toString()).toBe('**one** **two**') + expect(s.view.state.selection.ranges).toHaveLength(2) + s.runEditorCommand('insert-link') + expect(s.view.state.doc.toString()).toBe('**[one]()** **[two]()**') + expect(s.view.state.selection.ranges).toHaveLength(2) + }) + + it('replaces only the reversed main selection for a wikilink snippet', async () => { + const s = await setup('one two') + s.view.dispatch({ + selection: EditorSelection.create( + [EditorSelection.range(0, 3), EditorSelection.range(7, 4)], + 1 + ) + }) + s.runEditorCommand('insert-wikilink') + expect(s.view.state.doc.toString()).toBe('one [[]]') + expect(s.view.state.selection.ranges).toHaveLength(1) + expect(s.view.state.selection.main.head).toBe(6) + }) + + it('ignores unsupported command names passed by an untyped host', async () => { + const s = await setup() + expect(s.runEditorCommand('dispatch' as EditorCommand)).toBe(false) + expect(s.view.state.doc.toString()).toBe('hello') + expect(s.view.hasFocus).toBe(false) + }) +}) diff --git a/packages/app-core/src/editor-host.test.ts b/packages/app-core/src/editor-host.test.ts new file mode 100644 index 00000000..5439fe7e --- /dev/null +++ b/packages/app-core/src/editor-host.test.ts @@ -0,0 +1,217 @@ +// @vitest-environment jsdom + +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const views: EditorView[] = [] + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { + configurable: true, + value: { getCapabilities: () => ({}) } + }) +}) + +afterEach(() => { + for (const view of views.splice(0)) view.destroy() + vi.restoreAllMocks() + document.body.replaceChildren() +}) + +async function setup() { + const api = await import('./editor') + const { useStore } = await import('./store') + const { noteEditorHostExtension } = await import('./lib/editor-host') + const { registerNoteEditor } = await import('./lib/note-editor-context') + function createView() { + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: 'hello', + selection: { anchor: 5 }, + extensions: [noteEditorHostExtension()] + }) + }) + views.push(view) + registerNoteEditor(view, () => 'one.md', useStore.getState().activePaneId) + useStore.setState({ + vault: { root: '/test', name: 'Test' }, + selectedPath: 'one.md', + editorViewRef: view, + activeNote: { path: 'one.md', body: 'hello' } as NonNullable< + ReturnType['activeNote'] + > + }) + return view + } + return { ...api, useStore, createView } +} + +function typingAttributes(view: EditorView) { + return ['autocorrect', 'autocapitalize', 'spellcheck', 'writingsuggestions'].map((name) => + view.contentDOM.getAttribute(name) + ) +} + +function measureQueue(view: EditorView) { + const queue: NonNullable[0]>[] = [] + vi.spyOn(view, 'requestMeasure').mockImplementation((request) => { + if (request) queue.push(request) + }) + vi.spyOn(view.dom, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 0, 400, 300)) + vi.spyOn(view.scrollDOM, 'getBoundingClientRect').mockImplementation(() => { + const layout = + Number.parseFloat(view.dom.style.getPropertyValue('--zen-editor-host-bottom-inset')) || 0 + return new DOMRect(0, 0, 400, 300 - layout) + }) + return async () => { + for (let count = 0; queue.length; count++) { + if (count > 10) throw new Error('Host layout did not stabilize') + const request = queue.shift()! + request.write?.(request.read(view), view) + } + await Promise.resolve() + } +} + +describe('public editor host integration', () => { + it('installs native typing before newly created editors can receive focus', async () => { + const s = await setup() + const registration = s.installEditorHost({ nativeTyping: true }) + const first = s.createView() + const second = s.createView() + expect(typingAttributes(first)).toEqual(['on', 'sentences', 'true', 'true']) + expect(typingAttributes(second)).toEqual(typingAttributes(first)) + expect(first.hasFocus).toBe(false) + registration.dispose() + expect(typingAttributes(first)).toEqual(['off', 'off', 'false', 'false']) + expect(typingAttributes(s.createView())).toEqual(typingAttributes(first)) + }) + + it('configures existing views without changing their document, selection, or focus', async () => { + const s = await setup() + const view = s.createView() + const before = view.state + s.installEditorHost({ nativeTyping: true }) + expect(typingAttributes(view)).toEqual(['on', 'sentences', 'true', 'true']) + expect(view.state.doc).toBe(before.doc) + expect(view.state.selection).toBe(before.selection) + expect(view.hasFocus).toBe(false) + }) + + it('does not call a host measurer for a destroyed editor', async () => { + const s = await setup() + const view = s.createView() + const measure = vi.fn(() => ({ scroll: 20 })) + const host = s.installEditorHost({ measureBottomInsets: measure }) + const flush = measureQueue(view) + host.refresh() + view.destroy() + await flush() + expect(measure).not.toHaveBeenCalled() + }) + + it('lets only the latest registration refresh or dispose the host configuration', async () => { + const s = await setup() + const old = s.installEditorHost({ nativeTyping: true }) + const view = s.createView() + const measure = vi.fn(() => ({ scroll: 10 })) + const current = s.installEditorHost({ nativeTyping: true, measureBottomInsets: measure }) + const flush = measureQueue(view) + old.dispose() + old.refresh() + await flush() + expect(measure).not.toHaveBeenCalled() + expect(typingAttributes(view)).toEqual(['on', 'sentences', 'true', 'true']) + current.refresh() + await flush() + expect(measure).toHaveBeenCalled() + current.dispose() + current.dispose() + expect(typingAttributes(view)).toEqual(['off', 'off', 'false', 'false']) + }) + + it('remeasures the shrunken scroller and avoids counting the overlay twice', async () => { + const s = await setup() + const view = s.createView() + const seen: number[] = [] + const host = s.installEditorHost({ + measureBottomInsets: (viewport) => { + expect(Object.isFrozen(viewport)).toBe(true) + expect(Object.isFrozen(viewport.editor)).toBe(true) + seen.push(viewport.scroll.bottom) + return { layout: 80, scroll: Math.max(0, viewport.scroll.bottom - 250) } + } + }) + const flush = measureQueue(view) + host.refresh() + await flush() + expect(seen).toEqual([300, 220]) + expect(view.dom.style.getPropertyValue('--zen-editor-host-bottom-inset')).toBe('80px') + expect(view.state.facet(EditorView.scrollMargins).map((source) => source(view))).toContainEqual( + { bottom: 0 } + ) + host.dispose() + expect(view.dom.style.getPropertyValue('--zen-editor-host-bottom-inset')).toBe('') + }) + + it('clamps invalid insets and recovers from host measurement failures', async () => { + const s = await setup() + const view = s.createView() + const measure = vi.fn(() => ({ layout: -10, scroll: Infinity })) + const host = s.installEditorHost({ measureBottomInsets: measure }) + const flush = measureQueue(view) + host.refresh() + await flush() + expect(view.dom.style.getPropertyValue('--zen-editor-host-bottom-inset')).toBe('0px') + measure.mockImplementation(() => { + throw new Error('Host UI was disposed') + }) + host.refresh() + await expect(flush()).resolves.toBeUndefined() + measure.mockImplementation(() => ({ layout: 0, scroll: 9999 })) + host.refresh() + await flush() + expect(view.state.facet(EditorView.scrollMargins).map((source) => source(view))).toContainEqual( + { bottom: 300 } + ) + }) + + it('reveals only a focused, still-current editor after measurement', async () => { + const s = await setup() + const view = s.createView() + s.installEditorHost({ measureBottomInsets: () => ({ scroll: 20 }) }) + const flush = measureQueue(view) + expect(s.revealEditorCaret()).toBe(false) + view.focus() + const dispatch = vi.spyOn(view, 'dispatch') + expect(s.revealEditorCaret()).toBe(true) + expect(dispatch).not.toHaveBeenCalled() + await flush() + expect(dispatch).toHaveBeenCalledTimes(1) + expect(view.state.doc.toString()).toBe('hello') + expect(view.state.selection.main.head).toBe(5) + }) + + for (const change of ['note', 'vault', 'focus', 'dispose', 'destroy'] as const) { + it(`cancels a queued caret reveal after ${change} changes`, async () => { + const s = await setup() + const view = s.createView() + const host = s.installEditorHost({ measureBottomInsets: () => ({ scroll: 20 }) }) + const flush = measureQueue(view) + view.focus() + expect(s.revealEditorCaret()).toBe(true) + if (change === 'note') s.useStore.setState({ selectedPath: 'two.md' }) + if (change === 'vault') s.useStore.setState({ vault: { root: '/other', name: 'Other' } }) + if (change === 'focus') view.contentDOM.blur() + if (change === 'dispose') host.dispose() + if (change === 'destroy') view.destroy() + const dispatch = vi.spyOn(view, 'dispatch') + await flush() + expect(dispatch).not.toHaveBeenCalled() + }) + } +}) diff --git a/packages/app-core/src/editor.test.ts b/packages/app-core/src/editor.test.ts new file mode 100644 index 00000000..1498fdde --- /dev/null +++ b/packages/app-core/src/editor.test.ts @@ -0,0 +1,243 @@ +// @vitest-environment jsdom + +import { EditorSelection, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ImportedAsset } from '@bridge-contract/ipc' +import type { EditorInsertionTarget } from './editor' + +const views: EditorView[] = [] +const file = (name = 'one.pdf') => new File(['bytes'], name) +const asset = (name = 'one.pdf'): ImportedAsset => ({ name, path: `assets/${name}`, kind: 'pdf', markdown: `![[assets/${name}]]` }) +const image = { data: new Uint8Array([1, 2]), mimeType: 'image/png' } + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { configurable: true, value: { getCapabilities: () => ({}) } }) +}) +afterEach(() => { + for (const view of views.splice(0)) view.destroy() + document.body.replaceChildren() +}) + +async function setup(body = 'before after', anchor = 7, head = anchor) { + const { useStore } = await import('./store') + const api = await import('./editor') + const { registerNoteEditor } = await import('./lib/note-editor-context') + let editorPath: string | null = 'one.md' + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: body, selection: EditorSelection.single(anchor, head) }) + }) + views.push(view) + registerNoteEditor(view, () => editorPath, useStore.getState().activePaneId) + useStore.setState({ vault: { root: '/test-vault', name: 'Test' }, selectedPath: 'one.md', editorViewRef: view, + activeNote: { path: 'one.md', body } as NonNullable['activeNote']> }) + const importer = { + isCurrent: vi.fn(() => true), + importFile: vi.fn(async (_path: string, input: File) => asset(input.name)), + importPastedImage: vi.fn(async () => asset('paste.png')) + } + return { ...api, importer, view, useStore, setEditorPath: (path: string | null) => { editorPath = path } } +} + +describe('public editor attachment insertion', () => { + it('imports in order, inserts at the captured cursor, and returns the saved assets', async () => { + const s = await setup() + const target = s.captureEditorInsertion(s.importer)! + const result = await s.attachFiles(target, [file(), file('two.pdf')]) + expect(result).toEqual({ status: 'inserted', assets: [asset(), asset('two.pdf')] }) + expect(s.importer.importFile.mock.calls.map(([path, input]) => [path, input.name])).toEqual([ + ['one.md', 'one.pdf'], ['one.md', 'two.pdf'] + ]) + expect(s.view.state.doc.toString()).toBe('before \n\n![[assets/one.pdf]]\n\n![[assets/two.pdf]]\n\nafter') + expect(s.view.hasFocus).toBe(true) + }) + + it('preserves selected text for file attachment and replaces it for image paste', async () => { + const s = await setup('before selected after', 7, 15) + await s.attachFiles(s.captureEditorInsertion(s.importer)!, [file()]) + expect(s.view.state.doc.toString()).toBe('before selected\n\n![[assets/one.pdf]]\n\n after') + const paste = await setup('before selected after', 15, 7) + await paste.insertPastedImage(paste.captureEditorInsertion(paste.importer)!, image) + expect(paste.view.state.doc.toString()).toBe('before \n\n![[assets/paste.png]]\n\n after') + expect(paste.importer.importPastedImage).toHaveBeenCalledWith(image) + }) + + it('returns no target for unavailable, virtual, read-only, or transitioning editors', async () => { + const s = await setup() + s.useStore.setState({ vault: null }) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + s.useStore.setState({ vault: { root: '/test', name: 'Test' }, selectedPath: 'zen://tasks' }) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + s.useStore.setState({ selectedPath: 'other.md' }) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + s.useStore.setState({ selectedPath: 'one.md' }) + s.view.setState(EditorState.create({ doc: 'read only', extensions: EditorState.readOnly.of(true) })) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + s.view.destroy() + expect(s.captureEditorInsertion(s.importer)).toBeNull() + }) + + for (const change of ['note', 'document', 'cursor', 'vault', 'editor', 'pane', 'host', 'registered path', 'read-only'] as const) { + it(`rejects a changed ${change} before importing anything`, async () => { + const s = await setup() + const target = s.captureEditorInsertion(s.importer)! + if (change === 'note') s.useStore.setState({ selectedPath: 'two.md' }) + if (change === 'document') s.view.dispatch({ changes: { from: 0, insert: 'edit' } }) + if (change === 'cursor') s.view.dispatch({ selection: { anchor: 0 } }) + if (change === 'vault') s.useStore.setState({ vault: { root: '/other', name: 'Other' } }) + if (change === 'editor') s.useStore.setState({ editorViewRef: null }) + if (change === 'pane') s.useStore.setState({ activePaneId: 'different-pane' }) + if (change === 'host') s.importer.isCurrent.mockReturnValue(false) + if (change === 'registered path') s.setEditorPath('two.md') + if (change === 'read-only') s.view.setState(EditorState.create({ doc: 'before after', extensions: EditorState.readOnly.of(true) })) + expect(await s.attachFiles(target, [file()])).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importFile).not.toHaveBeenCalled() + }) + } + + it('stops a multi-file import after a note switch and preserves already saved files', async () => { + const s = await setup() + const focus = vi.spyOn(s.view, 'focus') + const original = s.view.state.doc.toString() + s.importer.importFile.mockImplementationOnce(async () => { + s.useStore.setState({ selectedPath: 'two.md' }) + return asset() + }) + expect(await s.attachFiles(s.captureEditorInsertion(s.importer)!, [file(), file('two.pdf')])) + .toEqual({ status: 'saved-only', assets: [asset()] }) + expect(s.importer.importFile).toHaveBeenCalledTimes(1) + expect(s.view.state.doc.toString()).toBe(original) + expect(focus).not.toHaveBeenCalled() + }) + + it('stops when the host switches vaults before the store catches up', async () => { + const s = await setup() + s.importer.importFile.mockImplementationOnce(async () => { + s.importer.isCurrent.mockReturnValue(false) + return asset() + }) + expect(await s.attachFiles(s.captureEditorInsertion(s.importer)!, [file(), file('two.pdf')])) + .toEqual({ status: 'saved-only', assets: [asset()] }) + expect(s.importer.importFile).toHaveBeenCalledTimes(1) + expect(s.view.state.doc.toString()).toBe('before after') + }) + + it('reports partial storage failure without inserting incomplete references', async () => { + const s = await setup() + s.importer.importFile.mockResolvedValueOnce(asset()).mockRejectedValueOnce(new Error('Disk full')) + expect(await s.attachFiles(s.captureEditorInsertion(s.importer)!, [file(), file('two.pdf')])) + .toEqual({ status: 'failed', assets: [asset()], error: 'Disk full' }) + expect(s.view.state.doc.toString()).toBe('before after') + }) + + it('allows each target only once, including while its import is in flight', async () => { + const s = await setup() + let finish!: (value: ImportedAsset) => void + s.importer.importFile.mockImplementation(() => new Promise(resolve => { finish = resolve })) + const target = s.captureEditorInsertion(s.importer)! + const first = s.attachFiles(target, [file()]) + expect(await s.attachFiles(target, [file()])).toEqual({ status: 'stale', assets: [] }) + finish(asset()) + expect((await first).status).toBe('inserted') + expect(await s.attachFiles(target, [file()])).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importFile).toHaveBeenCalledTimes(1) + }) + + it('cancels an in-flight insertion without deleting its saved asset', async () => { + const s = await setup() + const target = s.captureEditorInsertion(s.importer)! + s.importer.importFile.mockImplementationOnce(async () => { + s.cancelEditorInsertion(target) + return asset() + }) + expect(await s.attachFiles(target, [file()])).toEqual({ status: 'saved-only', assets: [asset()] }) + expect(s.view.state.doc.toString()).toBe('before after') + }) + + it('rejects stale image reads and image writes without inserting into another note', async () => { + const s = await setup() + const beforeRead = s.captureEditorInsertion(s.importer)! + s.view.dispatch({ selection: { anchor: 0 } }) + expect(await s.insertPastedImage(beforeRead, image)).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importPastedImage).not.toHaveBeenCalled() + s.importer.importPastedImage.mockImplementationOnce(async () => { + s.setEditorPath('other.md') + return asset('paste.png') + }) + expect(await s.insertPastedImage(s.captureEditorInsertion(s.importer)!, image)) + .toEqual({ status: 'saved-only', assets: [asset('paste.png')] }) + expect(s.view.state.doc.toString()).toBe('before after') + }) + + it('supports focused clipboard capture while preserving targets through picker blur', async () => { + const s = await setup() + expect(s.captureEditorInsertion(s.importer, { requireFocus: true })).toBeNull() + s.view.focus() + const target = s.captureEditorInsertion(s.importer, { requireFocus: true })! + s.view.contentDOM.blur() + expect(s.view.hasFocus).toBe(false) + expect((await s.attachFiles(target, [file()])).status).toBe('inserted') + }) + + it('treats host teardown as stale rather than throwing from validation', async () => { + const s = await setup() + const target = s.captureEditorInsertion(s.importer)! + s.importer.isCurrent.mockImplementation(() => { throw new Error('No active vault') }) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + expect(await s.attachFiles(target, [file()])).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importFile).not.toHaveBeenCalled() + }) + + it('rejects a destroyed view and a cancelled target before calling the host', async () => { + const s = await setup() + const destroyed = s.captureEditorInsertion(s.importer)! + const cancelled = s.captureEditorInsertion(s.importer)! + s.cancelEditorInsertion(cancelled) + expect(await s.attachFiles(cancelled, [file()])).toEqual({ status: 'stale', assets: [] }) + s.view.destroy() + expect(s.captureEditorInsertion(s.importer)).toBeNull() + expect(await s.attachFiles(destroyed, [file()])).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importFile).not.toHaveBeenCalled() + }) + + it('rejects fabricated tokens and tokens from another package instance', async () => { + const s = await setup() + expect(await s.attachFiles({} as EditorInsertionTarget, [file()])).toEqual({ status: 'stale', assets: [] }) + const target = s.captureEditorInsertion(s.importer)! + expect(Object.keys(target)).toEqual([]) + expect(Object.isFrozen(target)).toBe(true) + vi.resetModules() + const other = await import('./editor') + expect(await other.attachFiles(target, [file()])).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importFile).not.toHaveBeenCalled() + }) + + it('snapshots the file list before awaiting storage', async () => { + const s = await setup() + const files = [file()] + s.importer.importFile.mockImplementationOnce(async () => { + files.push(file('unexpected.pdf')) + return asset() + }) + expect(await s.attachFiles(s.captureEditorInsertion(s.importer)!, files)) + .toEqual({ status: 'inserted', assets: [asset()] }) + expect(s.importer.importFile).toHaveBeenCalledTimes(1) + }) + + it('reports an empty batch without inserting text or calling storage', async () => { + const s = await setup() + expect(await s.attachFiles(s.captureEditorInsertion(s.importer)!, [])) + .toEqual({ status: 'empty', assets: [] }) + expect(s.view.state.doc.toString()).toBe('before after') + expect(s.importer.importFile).not.toHaveBeenCalled() + }) + + it('rejects capture after switching panes before the editor reference catches up', async () => { + const s = await setup() + s.useStore.setState({ activePaneId: 'other-pane-showing-the-same-note' }) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + }) +}) diff --git a/packages/app-core/src/editor.ts b/packages/app-core/src/editor.ts new file mode 100644 index 00000000..8c83b05d --- /dev/null +++ b/packages/app-core/src/editor.ts @@ -0,0 +1,297 @@ +import { useSyncExternalStore } from 'react' +import { requestPaneMode } from './lib/pane-mode' +import type { EditorSelection, Text } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' +import type { ImportedAsset, PastedImageInput, VaultInfo } from '@bridge-contract/ipc' +import { useStore } from './store' +import { formatImportedAssetsForInsertion } from './lib/editor-drops' +import { noteEditorMatches } from './lib/note-editor-context' +import { runNoteEditorCommand } from './lib/editor-commands' +import { installNoteEditorHost, requestNoteEditorReveal } from './lib/editor-host' + +export interface EditorBounds { + readonly top: number + readonly bottom: number + readonly left: number + readonly right: number + readonly width: number + readonly height: number +} + +export interface EditorViewport { + readonly editor: EditorBounds + readonly scroll: EditorBounds +} + +export interface EditorBottomInsets { + /** Reserve physical space below the scroller, for native selection handles. */ + readonly layout?: number + /** Additional clearance inside the remaining scroll viewport. */ + readonly scroll?: number +} + +export interface EditorHostOptions { + readonly nativeTyping?: boolean + /** Read-only measurement callback. Return CSS pixels; do not change layout here. */ + readonly measureBottomInsets?: (viewport: EditorViewport) => EditorBottomInsets +} + +export interface EditorHostRegistration { + refresh(): void + dispose(): void +} + +/** Configure existing and future editors. The newest registration owns the configuration. */ +export function installEditorHost(options: EditorHostOptions): EditorHostRegistration { + return installNoteEditorHost(options) +} + +/** Schedule a focused note's caret reveal after measuring host overlays. Never takes focus. */ +export function revealEditorCaret(): boolean { + const editor = currentNoteEditor() + if (!editor || !editor.view.hasFocus) return false + return requestNoteEditorReveal(editor.view, () => { + const current = currentNoteEditor() + return ( + current?.view === editor.view && + current.path === editor.path && + current.vault === editor.vault && + editor.view.hasFocus + ) + }) +} + +/** Semantic toolbar actions. Hosts never receive the editor's command or view objects. */ +export type EditorCommand = + | 'undo' + | 'redo' + | 'open-search' + | 'close-search' + | 'toggle-bold' + | 'toggle-italic' + | 'toggle-strikethrough' + | 'toggle-highlight' + | 'toggle-inline-code' + | 'set-bullet-list' + | 'set-task-list' + | 'cycle-heading' + | 'insert-link' + | 'insert-wikilink' + | 'insert-tag' + | 'indent' + | 'outdent' + +function currentNoteEditor(): { view: EditorView; path: string; vault: VaultInfo } | null { + const { + editorViewRef: view, + selectedPath: path, + vault, + activeNote, + activePaneId + } = useStore.getState() + if ( + !view || + !vault || + !path || + path.startsWith('zen://') || + !view.dom.isConnected || + activeNote?.path !== path || + !noteEditorMatches(view, path, activePaneId) + ) + return null + return { view, path, vault } +} + +/** Run immediately against the active note. False means unavailable or not handled. */ +export function runEditorCommand(command: EditorCommand): boolean { + const editor = currentNoteEditor() + return editor ? runNoteEditorCommand(editor.view, command) : false +} + +/** Inspect text selections, including in read-only notes, without exposing them. */ +export function hasEditorSelection(): boolean { + const editor = currentNoteEditor() + return editor ? editor.view.state.selection.ranges.some((range) => !range.empty) : false +} + +/** Bind these operations to one host vault before opening a picker or reading a clipboard. */ +export interface EditorAssetImporter { + /** Check the host's actual vault identity, even while renderer state is catching up. */ + isCurrent(): boolean + importFile(notePath: string, file: File): Promise + importPastedImage(input: PastedImageInput): Promise +} + +declare const insertionTarget: unique symbol +/** Opaque, single-use context. It contains no public editor or filesystem state. */ +export interface EditorInsertionTarget { + readonly [insertionTarget]: true +} + +export type EditorInsertionResult = + | { status: 'inserted' | 'stale' | 'saved-only' | 'empty'; assets: readonly ImportedAsset[] } + | { status: 'failed'; assets: readonly ImportedAsset[]; error: string } + +interface InsertionContext { + view: EditorView + path: string + vault: VaultInfo + document: Text + selection: EditorSelection + importer: EditorAssetImporter + started: boolean +} +const insertions = new WeakMap() + +function hostIsCurrent(importer: EditorAssetImporter): boolean { + try { + return importer.isCurrent() + } catch { + return false + } +} + +/** Capture before asynchronous host work. Losing focus to a picker is allowed. */ +export function captureEditorInsertion( + importer: EditorAssetImporter, + options: { requireFocus?: boolean } = {} +): EditorInsertionTarget | null { + const editor = currentNoteEditor() + if (!editor) return null + const { view, path, vault } = editor + if (view.state.readOnly || (options.requireFocus && !view.hasFocus) || !hostIsCurrent(importer)) + return null + const target = Object.freeze({}) as EditorInsertionTarget + insertions.set(target, { + view, + path, + vault, + document: view.state.doc, + selection: view.state.selection, + importer, + started: false + }) + return target +} + +/** Invalidate insertion on dismissal/disposal. An in-flight host save cannot be undone. */ +export function cancelEditorInsertion(target: EditorInsertionTarget): void { + insertions.delete(target) +} + +function isCurrent(target: EditorInsertionTarget, context: InsertionContext): boolean { + const state = useStore.getState() + const { view } = context + return ( + insertions.get(target) === context && + state.vault === context.vault && + state.selectedPath === context.path && + state.activeNote?.path === context.path && + state.editorViewRef === view && + view.dom.isConnected && + noteEditorMatches(view, context.path, state.activePaneId) && + !view.state.readOnly && + view.state.doc === context.document && + view.state.selection.eq(context.selection) && + hostIsCurrent(context.importer) + ) +} + +function staleResult(assets: ImportedAsset[]): EditorInsertionResult { + return { status: assets.length ? 'saved-only' : 'stale', assets } +} + +async function importAndInsert( + target: EditorInsertionTarget, + inputs: readonly T[], + save: (context: InsertionContext, input: T) => Promise, + replaceSelection: boolean +): Promise { + const context = insertions.get(target) + if (!context || context.started) return { status: 'stale', assets: [] } + context.started = true + const assets: ImportedAsset[] = [] + try { + if (!isCurrent(target, context)) return staleResult(assets) + if (inputs.length === 0) return { status: 'empty', assets } + for (const input of inputs) { + if (!isCurrent(target, context)) return staleResult(assets) + assets.push(await save(context, input)) + if (!isCurrent(target, context)) return staleResult(assets) + } + const { view, document, selection } = context + const from = replaceSelection ? selection.main.from : selection.main.head + const to = replaceSelection ? selection.main.to : from + const before = from > 0 ? document.sliceString(from - 1, from) : '' + const after = document.sliceString(to, to + 1) + const insert = formatImportedAssetsForInsertion(assets, before, after) + view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } }) + view.focus() + return { status: 'inserted', assets } + } catch (error) { + return { + status: 'failed', + assets, + error: error instanceof Error ? error.message : 'Could not import the attachment.' + } + } finally { + insertions.delete(target) + } +} + +/** Import files serially and insert at the captured cursor only if its context still matches. */ +export function attachFiles( + target: EditorInsertionTarget, + files: readonly File[] +): Promise { + return importAndInsert( + target, + [...files], + (context, file) => context.importer.importFile(context.path, file), + false + ) +} + +/** Replace the captured selection after an asynchronous clipboard read. */ +export function insertPastedImage( + target: EditorInsertionTarget, + input: PastedImageInput +): Promise { + return importAndInsert( + target, + [input], + (context, image) => context.importer.importPastedImage(image), + true + ) +} + +export type EditorMode = 'edit' | 'preview' | 'split' +export interface EditorPresentation { + readonly path: string | null + readonly hasOpenNote: boolean + readonly mode: EditorMode +} +let presentation: EditorPresentation | undefined +export function getEditorPresentation(): EditorPresentation { + const state = useStore.getState() + const path = state.selectedPath + const sticky = state.paneStickyModes[state.activePaneId] + const mode = state.keepViewModeAcrossNotes && sticky ? sticky + : (path ? state.paneModes[state.activePaneId]?.[path] : undefined) ?? state.defaultPaneMode + const hasOpenNote = !!path && state.activeNote?.path === path + if (!presentation || presentation.path !== path || presentation.mode !== mode || presentation.hasOpenNote !== hasOpenNote) + presentation = Object.freeze({ path, mode, hasOpenNote }) + return presentation +} +export function subscribeEditorPresentation(listener: () => void): () => void { + let previous = getEditorPresentation() + return useStore.subscribe(() => { + const next = getEditorPresentation() + if (next === previous) return + previous = next; listener() + }) +} +export function useEditorPresentation(): EditorPresentation { + return useSyncExternalStore(subscribeEditorPresentation, getEditorPresentation, getEditorPresentation) +} +export function setEditorMode(mode: EditorMode): void { requestPaneMode(mode) } diff --git a/packages/app-core/src/folder-actions.test.ts b/packages/app-core/src/folder-actions.test.ts new file mode 100644 index 00000000..49a68c89 --- /dev/null +++ b/packages/app-core/src/folder-actions.test.ts @@ -0,0 +1,744 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { databaseTabPath } from '@shared/databases' +import { parseTasksFromBody } from '@shared/tasks' +import { makeLeaf, allLeaves } from './lib/pane-layout' + +beforeEach(() => { + vi.resetModules() + localStorage.clear() +}) +afterEach(() => { + vi.useRealTimers() +}) + +async function setup(root = false) { + const prefix = root ? '' : 'My Notes/' + let folders = ['Work', 'Work/People.base', 'Other'] + const files = new Map([ + [`${prefix}Work/Note.md`, 'Saved body.\n'], + [`${prefix}Work/People.base/data.csv`, 'id,Name\n1,Example\n'], + [`${prefix}Other/Keep.md`, 'Unchanged.\n'] + ]) + const meta = (path: string, body: string) => ({ + path, + title: path.split('/').pop()!, + folder: 'inbox' as const, + subpath: path.slice(prefix.length, path.lastIndexOf('/')), + siblingOrder: 0, + createdAt: 0, + updatedAt: 1, + size: body.length, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '' + }) + const folderRows = () => + folders.map((subpath) => ({ + folder: 'inbox' as const, + subpath, + siblingOrder: 0 + })) + const notes = () => [...files].filter(([p]) => p.endsWith('.md')).map(([p, b]) => meta(p, b)) + const bridge = { + getCapabilities: () => ({}), + listNotes: async () => notes(), + listFolders: async () => folderRows(), + hasAssetsDir: async () => false, + scanTasks: async () => [], + scanTasksForPath: async () => [], + listAssets: async () => [], + getRemoteWorkspaceInfo: async () => null, + readNote: async (path: string) => ({ + ...meta(path, files.get(path)!), + body: files.get(path)! + }), + writeNote: vi.fn(async (path: string, body: string) => { + files.set(path, body) + return meta(path, body) + }), + writeDatabaseRows: vi.fn(async (path: string, rows: unknown) => { + files.set(path, JSON.stringify(rows)) + }), + writeDatabaseSchema: vi.fn(async (path: string, _schema: unknown, rows: unknown) => { + files.set(path, JSON.stringify(rows)) + }), + setVaultSettings: vi.fn(async (settings) => settings), + createFolder: vi.fn(async (_folder: string, directory: string) => { + folders.push(directory) + }), + renameFolder: vi.fn(async (_folder: string, from: string, to: string) => { + folders = folders.map((path) => + path === from || path.startsWith(`${from}/`) ? to + path.slice(from.length) : path + ) + for (const [path, body] of [...files]) + if (path.startsWith(`${prefix}${from}/`)) { + files.delete(path) + files.set(`${prefix}${to}/${path.slice(`${prefix}${from}/`.length)}`, body) + } + return to + }), + deleteFolder: vi.fn(async (_folder: string, directory: string) => { + folders = folders.filter((path) => path !== directory && !path.startsWith(`${directory}/`)) + for (const path of [...files.keys()]) + if (path.startsWith(`${prefix}${directory}/`)) files.delete(path) + }) + } + Object.defineProperty(window, 'zen', { configurable: true, value: bridge }) + const { useStore } = await import('./store') + const path = `${prefix}Work/Note.md` + const csv = `${prefix}Work/People.base/data.csv` + const tab = databaseTabPath(csv) + const leaf = makeLeaf([path, tab, `${prefix}Other/Keep.md`], path) + useStore.setState({ + vault: { root: '/test', name: 'Test' }, + notes: notes(), + folders: folderRows(), + vaultSettings: { + ...useStore.getState().vaultSettings, + primaryNotesLocation: root ? 'root' : 'inbox', + systemFolderPaths: { inbox: 'My Notes' } + }, + paneLayout: leaf, + activePaneId: leaf.id, + selectedPath: path, + noteContents: Object.fromEntries( + notes().map((n) => [n.path, { ...n, body: files.get(n.path)! }]) + ), + noteDirty: { [path]: true }, + activeNote: { ...meta(path, 'Unsaved edit.\n'), body: 'Unsaved edit.\n' }, + activeDirty: true, + databases: { + [csv]: { + version: 1, + path: csv, + title: 'People', + fields: [], + rows: [], + views: [], + activeViewId: '', + idFieldId: 'id' + } + } + }) + useStore.setState({ + noteContents: { + ...useStore.getState().noteContents, + [path]: { ...meta(path, 'Unsaved edit.\n'), body: 'Unsaved edit.\n' } + } + }) + return { + useStore, + bridge, + files, + prefix, + path, + csv, + tab, + tabs: () => allLeaves(useStore.getState().paneLayout).flatMap((leaf) => leaf.tabs) + } +} + +describe('folder mutation state', () => { + it.each([false, true])('renames open notes and database tabs with root mode %s', async (root) => { + const s = await setup(root) + await s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + expect(s.tabs()).toEqual([ + `${s.prefix}Renamed/Note.md`, + databaseTabPath(`${s.prefix}Renamed/People.base/data.csv`), + `${s.prefix}Other/Keep.md` + ]) + const state = s.useStore.getState() + expect(state.activeNote?.body).toBe('Unsaved edit.\n') + expect(state.selectedPath).toBe(`${s.prefix}Renamed/Note.md`) + expect(state.databases[`${s.prefix}Renamed/People.base/data.csv`]?.path).toBe( + `${s.prefix}Renamed/People.base/data.csv` + ) + expect(state.databases[s.csv]).toBeUndefined() + await state.persistNote(`${s.prefix}Renamed/Note.md`) + expect(s.files.get(`${s.prefix}Renamed/Note.md`)).toBe('Unsaved edit.\n') + expect(s.files.has(s.path)).toBe(false) + expect(s.files.get(`${s.prefix}Other/Keep.md`)).toBe('Unchanged.\n') + }) + + it.each([false, true])( + 'deletes only the target folder and closes database tabs with root mode %s', + async (root) => { + const s = await setup(root) + await s.useStore.getState().deleteFolder('inbox', 'Work') + expect(s.tabs()).toEqual([`${s.prefix}Other/Keep.md`]) + expect(s.useStore.getState().databases[s.csv]).toBeUndefined() + expect(s.useStore.getState().noteContents[s.path]).toBeUndefined() + expect(s.files.size).toBe(1) + expect(s.files.get(`${s.prefix}Other/Keep.md`)).toBe('Unchanged.\n') + } + ) + + it.each(['createFolder', 'renameFolder', 'deleteFolder'] as const)( + 'ignores a %s response after a vault switch', + async (action) => { + const s = await setup() + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + if (action === 'renameFolder') + s.bridge.renameFolder.mockImplementationOnce(async () => { + await gate + return 'Renamed' + }) + else + s.bridge[action].mockImplementationOnce(async () => { + await gate + }) + const promise = + action === 'renameFolder' + ? s.useStore.getState()[action]('inbox', 'Work', 'Renamed') + : s.useStore.getState()[action]('inbox', 'Work') + await vi.waitFor(() => expect(s.bridge[action]).toHaveBeenCalled()) + s.useStore.setState({ + vault: { root: '/other', name: 'Other' }, + notes: [], + folders: [], + view: { kind: 'folder', folder: 'inbox', subpath: 'Other vault' } + }) + const before = s.useStore.getState() + release() + await promise + expect(s.useStore.getState()).toBe(before) + expect(s.bridge.setVaultSettings).not.toHaveBeenCalled() + } + ) + it('uses the canonical directory returned by the host', async () => { + const s = await setup(true) + const rename = s.bridge.renameFolder.getMockImplementation()! + s.bridge.renameFolder.mockImplementationOnce((folder, from) => + rename(folder, from, 'Canonical') + ) + await s.useStore.getState().renameFolder('inbox', 'Work', 'Requested') + expect(s.useStore.getState().selectedPath).toBe('Canonical/Note.md') + expect(s.files.has('Canonical/Note.md')).toBe(true) + }) + + it('preserves note and database edits made while a rename is pending', async () => { + const s = await setup(true) + const initial = s.useStore.getState().databases[s.csv] + const before = { ...initial, rows: [{ id: '1', cells: { name: 'Before rename' } }] } + s.useStore.getState().updateDatabaseRows(s.csv, before) + const rename = s.bridge.renameFolder.getMockImplementation()! + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + s.bridge.renameFolder.mockImplementationOnce(async (...args) => { + await gate + return rename(...args) + }) + const operation = s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + await vi.waitFor(() => expect(s.bridge.renameFolder).toHaveBeenCalled()) + expect(s.bridge.writeDatabaseRows).toHaveBeenCalledWith(s.csv, before.rows) + await s.useStore + .getState() + .applyChange({ kind: 'unlink', path: s.path, scope: 'content', folder: 'inbox' }) + await s.useStore + .getState() + .applyChange({ kind: 'unlink', path: s.csv, scope: 'database', folder: 'inbox' }) + expect(s.tabs()).toContain(s.path) + expect(s.tabs()).toContain(s.tab) + vi.useFakeTimers() + s.useStore.getState().updateNoteBody(s.path, 'Typed during rename.\n') + const during = { ...initial, rows: [{ id: '1', cells: { name: 'During rename' } }] } + s.useStore.getState().updateDatabaseRows(s.csv, during) + await vi.advanceTimersByTimeAsync(500) + expect(s.bridge.writeDatabaseRows).toHaveBeenCalledTimes(1) + release() + await operation + await vi.advanceTimersByTimeAsync(500) + expect(s.files.get('Renamed/Note.md')).toBe('Typed during rename.\n') + expect(s.files.get('Renamed/People.base/data.csv')).toBe(JSON.stringify(during.rows)) + expect([...s.files.keys()].some((path) => path.startsWith('Work/'))).toBe(false) + }) + + it('drains a pending database write before deletion and never recreates its files', async () => { + const s = await setup(true) + vi.useFakeTimers() + const doc = s.useStore.getState().databases[s.csv] + s.useStore + .getState() + .updateDatabaseRows(s.csv, { ...doc, rows: [{ id: '1', cells: { name: 'Pending' } }] }) + await s.useStore.getState().deleteFolder('inbox', 'Work/People.base') + await vi.advanceTimersByTimeAsync(1000) + expect(s.files.has(s.csv)).toBe(false) + expect(s.bridge.writeDatabaseRows).toHaveBeenCalledTimes(1) + expect(s.tabs()).not.toContain(s.tab) + expect(s.tabs()).toContain(s.path) + }) + + it('resumes pending saves at their original paths when a rename fails', async () => { + const s = await setup(true) + let reject!: (error: Error) => void + const gate = new Promise((_resolve, no) => { + reject = no + }) + s.bridge.renameFolder.mockImplementationOnce(() => gate) + const operation = s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + const failure = expect(operation).rejects.toThrow('Name taken') + await vi.waitFor(() => expect(s.bridge.renameFolder).toHaveBeenCalled()) + vi.useFakeTimers() + s.useStore.getState().updateNoteBody(s.path, 'Keep this edit.\n') + const doc = s.useStore.getState().databases[s.csv] + const rows = [{ id: '1', cells: { name: 'Keep this cell' } }] + s.useStore.getState().updateDatabaseRows(s.csv, { ...doc, rows }) + reject(new Error('Name taken')) + await failure + await vi.advanceTimersByTimeAsync(500) + expect(s.files.get(s.path)).toBe('Keep this edit.\n') + expect(s.files.get(s.csv)).toBe(JSON.stringify(rows)) + expect(s.tabs()).toContain(s.path) + }) + + it('ignores a listing fetched before a vault switch', async () => { + const s = await setup() + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const listing = s.bridge.listNotes + s.bridge.listNotes = async () => { + await gate + return listing() + } + const refresh = s.useStore.getState().refreshNotes() + s.useStore.setState({ vault: { root: '/other', name: 'Other' }, notes: [], folders: [] }) + const before = s.useStore.getState() + release() + await refresh + expect(s.useStore.getState()).toBe(before) + }) + it('does not restore an old database cache from a read completed after rename', async () => { + const s = await setup(true) + const old = s.useStore.getState().databases[s.csv] + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + Object.assign(s.bridge, { + openDatabase: async () => { + await gate + return old + } + }) + const read = s.useStore.getState().loadDatabase(s.csv) + await s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + release() + await read + expect(s.useStore.getState().databases[s.csv]).toBeUndefined() + expect(s.useStore.getState().databases['Renamed/People.base/data.csv']).toBeDefined() + expect(s.tabs()).not.toContain(s.tab) + }) + + it('rejects an old listing completed after a rename and its fresh listing', async () => { + const s = await setup(true) + const notes = await s.bridge.listNotes() + const folders = await s.bridge.listFolders() + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const originalNotes = s.bridge.listNotes + const originalFolders = s.bridge.listFolders + s.bridge.listNotes = async () => { + await gate + return notes + } + s.bridge.listFolders = async () => { + await gate + return folders + } + const oldRefresh = s.useStore.getState().refreshNotes() + s.bridge.listNotes = originalNotes + s.bridge.listFolders = originalFolders + await s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + release() + await oldRefresh + expect(s.useStore.getState().folders.some((row) => row.subpath === 'Work')).toBe(false) + expect(s.useStore.getState().notes.some((row) => row.path === 'Renamed/Note.md')).toBe(true) + expect(s.tabs()).toContain('Renamed/Note.md') + }) + + it('lets a waiting vault switch flush edits at the renamed path after invalidation', async () => { + const s = await setup(true) + let current = true + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const rename = s.bridge.renameFolder.getMockImplementation()! + s.bridge.renameFolder.mockImplementationOnce(async (...args) => { + await gate + return rename(...args) + }) + const operation = s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed', () => current) + await vi.waitFor(() => expect(s.bridge.renameFolder).toHaveBeenCalled()) + s.useStore.getState().updateNoteBody(s.path, 'Save before switching.\n') + const doc = s.useStore.getState().databases[s.csv] + const rows = [{ id: '1', cells: { name: 'Save before switching' } }] + s.useStore.getState().updateDatabaseRows(s.csv, { ...doc, rows }) + current = false + const flush = s.useStore.getState().flushDirtyNotes() + release() + await operation + await flush + expect(s.files.get('Renamed/Note.md')).toBe('Save before switching.\n') + expect(s.files.get('Renamed/People.base/data.csv')).toBe(JSON.stringify(rows)) + expect(s.files.has(s.csv)).toBe(false) + expect(s.files.has(s.path)).toBe(false) + expect(s.useStore.getState().selectedPath).toBe('Renamed/Note.md') + }) + + it('does not resume old-path writes after an uncertain rollback', async () => { + const s = await setup(true) + let reject!: (error: Error) => void + const gate = new Promise((_yes, no) => { + reject = no + }) + s.bridge.renameFolder.mockImplementationOnce(() => gate) + const operation = s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + const failure = expect(operation).rejects.toThrow('FOLDER_STATE_UNCERTAIN:') + await vi.waitFor(() => expect(s.bridge.renameFolder).toHaveBeenCalled()) + vi.useFakeTimers() + s.useStore.getState().updateNoteBody(s.path, 'Keep in memory.\n') + const writes = s.bridge.writeNote.mock.calls.length + reject(new Error('FOLDER_STATE_UNCERTAIN: simulated rollback failure')) + await failure + await vi.advanceTimersByTimeAsync(1000) + await expect(s.useStore.getState().flushDirtyNotes()).rejects.toThrow('Reload the vault') + expect(s.bridge.writeNote).toHaveBeenCalledTimes(writes) + expect(s.useStore.getState().noteContents[s.path].body).toBe('Keep in memory.\n') + }) + + it.each(['rename', 'delete'] as const)( + 'reconciles path-bearing state during a waiting switch: %s', + async (action) => { + const s = await setup(true) + const state = s.useStore.getState() + const asset = { + path: 'Work/image.png', + name: 'image.png', + kind: 'image' as const, + siblingOrder: 0, + size: 1, + updatedAt: 1 + } + const tasks = parseTasksFromBody('- [ ] Keep task', { + path: s.path, + title: 'Note', + folder: 'inbox' + }) + s.useStore.setState({ + view: { kind: 'folder', folder: 'inbox', subpath: 'Work' }, + paneModes: { [state.activePaneId]: { [s.path]: 'preview' } }, + noteRefs: { [s.path]: { path: asset.path, kind: 'asset', fragment: 'page=2' } }, + manualNoteOrder: { Work: ['Work/Z.md', s.path] }, + vaultSettings: { + ...state.vaultSettings, + favorites: ['inbox:Work', s.path, 'Other/Keep.md'] + }, + assetFiles: [asset], + vaultTasks: tasks + }) + localStorage.setItem( + 'zen.notes.manualOrder./test', + JSON.stringify(s.useStore.getState().manualNoteOrder) + ) + let current = true + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const rename = s.bridge.renameFolder.getMockImplementation()! + const remove = s.bridge.deleteFolder.getMockImplementation()! + s.bridge.renameFolder.mockImplementationOnce(async (...args) => { + await gate + return rename(...args) + }) + s.bridge.deleteFolder.mockImplementationOnce(async (...args) => { + await gate + return remove(...args) + }) + const operation = + action === 'rename' + ? state.renameFolder('inbox', 'Work', 'Renamed', () => current) + : state.deleteFolder('inbox', 'Work', () => current) + await vi.waitFor(() => + expect( + action === 'rename' ? s.bridge.renameFolder : s.bridge.deleteFolder + ).toHaveBeenCalled() + ) + const oldNotes = await s.bridge.listNotes() + const oldFolders = await s.bridge.listFolders() + let releaseRead!: () => void + const readGate = new Promise((resolve) => { + releaseRead = resolve + }) + s.bridge.listNotes = async () => { + await readGate + return oldNotes + } + s.bridge.listFolders = async () => { + await readGate + return oldFolders + } + Object.assign(s.bridge, { + listAssets: async () => { + await readGate + return [asset] + } + }) + const reads = [state.refreshNotes(), state.refreshAssets()] + current = false + release() + await operation + releaseRead() + await Promise.all(reads) + const next = s.useStore.getState() + expect(s.bridge.setVaultSettings).toHaveBeenCalled() + expect(s.bridge.setVaultSettings.mock.calls.at(-1)?.[0].favorites).toEqual( + action === 'rename' + ? ['inbox:Renamed', 'Renamed/Note.md', 'Other/Keep.md'] + : ['Other/Keep.md'] + ) + expect(next.notes.some((n) => n.path.startsWith('Work/'))).toBe(false) + expect(next.folders.some((f) => f.subpath === 'Work')).toBe(false) + expect(next.paneModes[state.activePaneId][s.path]).toBeUndefined() + expect(next.noteRefs[s.path]).toBeUndefined() + expect(next.manualNoteOrder.Work).toBeUndefined() + expect(JSON.parse(localStorage.getItem('zen.notes.manualOrder./test')!)).toEqual( + next.manualNoteOrder + ) + if (action === 'rename') { + expect(next.view).toEqual({ kind: 'folder', folder: 'inbox', subpath: 'Renamed' }) + expect(next.paneModes[state.activePaneId]['Renamed/Note.md']).toBe('preview') + expect(next.noteRefs['Renamed/Note.md']).toEqual({ + path: 'Renamed/image.png', + kind: 'asset', + fragment: 'page=2' + }) + expect(next.manualNoteOrder.Renamed).toEqual(['Renamed/Z.md', 'Renamed/Note.md']) + expect(next.assetFiles[0].path).toBe('Renamed/image.png') + expect(next.vaultTasks[0]).toMatchObject({ + sourcePath: 'Renamed/Note.md', + id: 'Renamed/Note.md#0' + }) + } else { + expect(next.view).toEqual({ kind: 'folder', folder: 'inbox', subpath: '' }) + expect(next.assetFiles).toEqual([]) + expect(next.vaultTasks).toEqual([]) + } + } + ) + + it('rejects note and task reads that finish after their folder moves', async () => { + const s = await setup(true) + const note = s.useStore.getState().noteContents[s.path] + s.useStore.setState({ noteContents: {}, noteDirty: {} }) + const tasks = parseTasksFromBody('- [ ] Keep task', { + path: s.path, + title: 'Note', + folder: 'inbox' + }) + s.useStore.setState({ vaultTasks: tasks }) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + Object.assign(s.bridge, { + readNote: async () => { + await gate + return note + }, + scanTasks: async () => { + await gate + return tasks + }, + scanTasksForPath: async () => { + await gate + return tasks + } + }) + const reads = [ + s.useStore.getState().selectNote(s.path), + s.useStore.getState().refreshTasks(), + s.useStore.getState().rescanTasksForPath(s.path) + ] + await s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + release() + await Promise.all(reads) + expect(s.useStore.getState().noteContents[s.path]).toBeUndefined() + expect(s.tabs()).not.toContain(s.path) + expect(s.useStore.getState().vaultTasks[0].sourcePath).toBe('Renamed/Note.md') + }) + + it.each(['read', 'write'] as const)( + 'drains a pending comment %s before moving its note', + async (kind) => { + const s = await setup(true) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const comments = [ + { + id: 'comment', + notePath: s.path, + body: 'Keep this thread', + createdAt: 1, + updatedAt: 1, + anchorStart: 0, + anchorEnd: 0, + anchorText: '', + resolvedAt: null + } + ] + const read = vi.fn(async () => { + if (kind === 'read') await gate + return comments + }) + const write = vi.fn(async () => { + await gate + return comments + }) + Object.assign(s.bridge, { readNoteComments: read, writeNoteComments: write }) + if (kind === 'write') s.useStore.setState({ noteComments: { [s.path]: comments } }) + const comment = + kind === 'read' + ? s.useStore.getState().loadNoteComments(s.path) + : s.useStore.getState().updateNoteComment(s.path, 'comment', { body: 'Keep this thread' }) + const operation = s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + await Promise.resolve() + expect(s.bridge.renameFolder).not.toHaveBeenCalled() + expect(await s.useStore.getState().loadNoteComments(s.path)).toEqual([]) + release() + await Promise.all([comment, operation]) + expect(s.useStore.getState().noteComments[s.path]).toBeUndefined() + expect(s.useStore.getState().noteComments['Renamed/Note.md']).toMatchObject([ + { notePath: 'Renamed/Note.md', body: 'Keep this thread' } + ]) + expect(read).toHaveBeenCalledTimes(kind === 'read' ? 1 : 0) + } + ) + it('allows database reload and clears task loading after a rejected rename', async () => { + const s = await setup(true) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const doc = s.useStore.getState().databases[s.csv] + const open = vi.fn(async () => { + await gate + return doc + }) + Object.assign(s.bridge, { + openDatabase: open, + scanTasks: async () => { + await gate + return [] + } + }) + const reads = [s.useStore.getState().loadDatabase(s.csv), s.useStore.getState().refreshTasks()] + s.bridge.renameFolder.mockRejectedValueOnce(new Error('Name taken')) + await expect(s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed')).rejects.toThrow( + 'Name taken' + ) + release() + await Promise.all(reads) + expect(s.useStore.getState().tasksLoading).toBe(false) + expect(s.useStore.getState().databasesLoading[s.csv]).toBe(false) + await s.useStore.getState().loadDatabase(s.csv) + expect(open).toHaveBeenCalledTimes(2) + }) + + it('renames a database with its open record page and edits made during the move', async () => { + const s = await setup(true) + const state = s.useStore.getState() + const page = 'Work/People.base/Record.md' + const note = { ...state.noteContents[s.path], path: page, body: 'Record body.' } + s.files.set(page, note.body) + s.useStore.setState({ + noteContents: { ...state.noteContents, [page]: note }, + databases: { + ...state.databases, + [s.csv]: { ...state.databases[s.csv], pages: { row: page } } + }, + paneLayout: makeLeaf([s.tab, page], page), + selectedPath: page + }) + s.useStore.setState({ activePaneId: allLeaves(s.useStore.getState().paneLayout)[0].id }) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const rename = vi.fn(async () => { + await gate + await s.bridge.renameFolder('inbox', 'Work/People.base', 'Work/Customers 2.base') + return 'Work/Customers 2.base/data.csv' + }) + Object.assign(s.bridge, { renameDatabase: rename }) + const operation = state.renameDatabase(s.csv, 'Customers', () => true) + await vi.waitFor(() => expect(rename).toHaveBeenCalled()) + s.useStore.getState().updateNoteBody(page, 'During rename.') + const doc = s.useStore.getState().databases[s.csv] + const rows = [{ id: 'row', cells: { name: 'Keep this edit' } }] + s.useStore.getState().updateDatabaseRows(s.csv, { ...doc, rows }) + const expectedPage = s.useStore.getState().noteContents[page].body + release() + await operation + await s.useStore.getState().flushDirtyNotes() + expect(s.files.has(page)).toBe(false) + expect(s.files.has(s.csv)).toBe(false) + expect(s.files.get('Work/Customers 2.base/Record.md')).toBe(expectedPage) + expect(s.files.get('Work/Customers 2.base/data.csv')).toBe(JSON.stringify(rows)) + expect(s.useStore.getState().databases['Work/Customers 2.base/data.csv'].pages?.row).toBe( + 'Work/Customers 2.base/Record.md' + ) + expect(s.tabs()).toEqual([ + databaseTabPath('Work/Customers 2.base/data.csv'), + 'Work/Customers 2.base/Record.md' + ]) + }) + + it('waits for database creation before switching and ignores its late UI result', async () => { + const s = await setup(true) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let current = true + const create = vi.fn(async () => { + await gate + return s.useStore.getState().databases[s.csv] + }) + Object.assign(s.bridge, { createDatabase: create }) + const operation = s.useStore + .getState() + .createDatabase('inbox', 'Work', undefined, () => current) + await vi.waitFor(() => expect(create).toHaveBeenCalled()) + current = false + let flushed = false + const flush = s.useStore + .getState() + .flushDirtyNotes() + .then(() => { + flushed = true + }) + await Promise.resolve() + expect(flushed).toBe(false) + release() + await Promise.all([operation, flush]) + expect(s.useStore.getState().selectedPath).toBe(s.path) + expect(flushed).toBe(true) + }) +}) diff --git a/packages/app-core/src/host.ts b/packages/app-core/src/host.ts new file mode 100644 index 00000000..a87f08e6 --- /dev/null +++ b/packages/app-core/src/host.ts @@ -0,0 +1,16 @@ +import type { ZenCapabilities, ZenAppInfo } from '@bridge-contract/bridge' + +export type HostKind = NonNullable +export interface HostInfo { + readonly kind: HostKind + readonly name: string + readonly version: string + readonly capabilities: Readonly +} +/** Capabilities are authoritative; OS names and renderer families are not feature flags. */ +export function getHostInfo(): HostInfo { + const info = window.zen.getAppInfo() + return Object.freeze({ kind: info.hostKind ?? (info.runtime === 'desktop' ? 'desktop' : 'browser'), + name: info.productName, version: info.version, + capabilities: Object.freeze({ ...window.zen.getCapabilities() }) }) +} diff --git a/packages/app-core/src/lib/app-update-state.test.ts b/packages/app-core/src/lib/app-update-state.test.ts index 8c97a8c0..6d2fb49a 100644 --- a/packages/app-core/src/lib/app-update-state.test.ts +++ b/packages/app-core/src/lib/app-update-state.test.ts @@ -60,6 +60,20 @@ describe('app update state labels', () => { expect(appUpdatePrimaryActionLabel(state)).toBeNull() }) + it('shows installation progress without offering a second install', () => { + const state = updateState('installing', { message: 'Approve the administrator prompt.' }) + expect(appUpdateBadgeLabel(state)).toBe('Installing') + expect(appUpdateNoticeLabel(state)).toBe(state.message) + expect(appUpdatePrimaryActionLabel(state)).toBeNull() + }) + + it('keeps failures visible and offers update details', () => { + const state = updateState('error', { availableVersion: '2.50.4' }) + expect(appUpdateBadgeLabel(state)).toBe('Update error') + expect(appUpdateNoticeLabel(state)).toBe('ZenNotes update needs attention') + expect(appUpdatePrimaryActionLabel(state)).toBe('Details') + }) + it('stays quiet when there is no update needing attention', () => { const state = updateState('not-available') diff --git a/packages/app-core/src/lib/app-update-state.ts b/packages/app-core/src/lib/app-update-state.ts index fd11eb0b..557f56ab 100644 --- a/packages/app-core/src/lib/app-update-state.ts +++ b/packages/app-core/src/lib/app-update-state.ts @@ -37,6 +37,10 @@ export function appUpdateBadgeLabel(state: AppUpdateState | null): string | null return 'Update' case 'downloaded': return 'Ready' + case 'installing': + return 'Installing' + case 'error': + return 'Update error' case 'downloading': return `${Math.round(state.progressPercent ?? 0)}%` default: @@ -50,6 +54,10 @@ export function appUpdateNoticeLabel(state: AppUpdateState | null): string | nul return `ZenNotes ${state.availableVersion ?? 'update'} is available` case 'downloaded': return `ZenNotes ${state.availableVersion ?? 'update'} is ready` + case 'installing': + return state.message + case 'error': + return 'ZenNotes update needs attention' case 'downloading': return `Downloading ZenNotes ${state.availableVersion ?? 'update'}` default: @@ -66,6 +74,8 @@ export function appUpdatePrimaryActionLabel(state: AppUpdateState | null): strin return 'Download' case 'downloaded': return 'Relaunch' + case 'error': + return 'Details' default: return null } diff --git a/packages/app-core/src/lib/browse-actions.ts b/packages/app-core/src/lib/browse-actions.ts new file mode 100644 index 00000000..5f7b753b --- /dev/null +++ b/packages/app-core/src/lib/browse-actions.ts @@ -0,0 +1,221 @@ +import { isWorkspaceTransitionPending } from './workspace-transition' +import { + csvPathForFormDir, + formDirContaining, + formTitleFromDir, + isFormDirName +} from '@shared/databases' +import { resolveFolderPath } from '@shared/system-folder-paths' +import { useStore } from '../store' +import { getConfirmRequest, confirmApp } from './confirm-requests' +import { getPromptRequest, promptApp } from './prompt-requests' +import { parentDirOf } from './manual-order' +import { resolveCreateLocation } from './vault-layout' + +export interface BrowseActionHost { + /** Capture the native vault token before requesting a dialog, then compare it here. */ + isCurrent(): boolean +} + +/** Host errors reject the promise; stale work never starts another mutation. */ +export type BrowseActionResult = 'completed' | 'cancelled' | 'stale' | 'unavailable' + +let pending = false + +function findDirectory(directory: string) { + if (!directory || formDirContaining(parentDirOf(directory))) return undefined + return useStore + .getState() + .folders.find((row) => row.folder === 'inbox' && row.subpath === directory) +} + +function primaryDirectory(): string { + const settings = useStore.getState().vaultSettings + return settings.primaryNotesLocation === 'root' + ? '' + : resolveFolderPath('inbox', settings.systemFolderPaths) +} + +function start(host: BrowseActionHost, directory: string, allowRoot = false) { + if (pending || getPromptRequest() || getConfirmRequest()) return null + const vault = useStore.getState().vault + const bridge = window.zen + const primary = primaryDirectory() + const isCurrent = (): boolean => { + try { + return ( + !isWorkspaceTransitionPending() && + !!vault && + useStore.getState().vault === vault && + window.zen === bridge && + primaryDirectory() === primary && + host.isCurrent() + ) + } catch { + return false + } + } + const targetExists = () => (allowRoot && directory === '') || !!findDirectory(directory) + if (!isCurrent() || !targetExists()) return null + pending = true + return { isCurrent, targetExists } +} + +function validateName(value: string): string | null { + const name = value.trim() + if (!name || name === '.' || name === '..' || /[\\/\u0000-\u001f]/.test(name)) + return 'Enter a folder name without / or \\.' + if (isFormDirName(name)) return 'The .base suffix is reserved for databases.' + return null +} + +/** Prompt for a child of a primary-notes directory. An empty directory means its root. */ +export async function requestCreateBrowseFolder( + host: BrowseActionHost, + directory = '' +): Promise { + if (formDirContaining(directory)) return 'unavailable' + const context = start(host, directory, true) + if (!context) return 'unavailable' + const { isCurrent, targetExists } = context + try { + const leaf = directory.split('/').pop() + const name = ( + await promptApp({ + title: leaf ? `New folder in ${leaf}` : 'New folder', + placeholder: 'Folder name', + okLabel: 'Create', + validate: validateName + }) + )?.trim() + if (!name || validateName(name)) return 'cancelled' + if (!isCurrent() || !targetExists()) return 'stale' + await useStore + .getState() + .createFolder('inbox', directory ? `${directory}/${name}` : name, isCurrent) + return isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} + +/** Prompt for an ordinary folder's leaf name. Database renaming uses its own feature. */ +export async function requestRenameBrowseFolder( + host: BrowseActionHost, + directory: string +): Promise { + if (formDirContaining(directory)) return 'unavailable' + const context = start(host, directory) + if (!context) return 'unavailable' + const { isCurrent, targetExists } = context + try { + const title = directory.split('/').pop()! + const name = ( + await promptApp({ + title: 'Rename folder', + initialValue: title, + okLabel: 'Rename', + validate: validateName + }) + )?.trim() + if (!name || name === title || validateName(name)) return 'cancelled' + if (!isCurrent() || !targetExists()) return 'stale' + const parent = parentDirOf(directory) + await useStore + .getState() + .renameFolder('inbox', directory, parent ? `${parent}/${name}` : name, isCurrent) + return isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} + +/** Confirm permanent deletion of an ordinary folder or a whole database directory. */ +export async function requestDeleteBrowseDirectory( + host: BrowseActionHost, + directory: string +): Promise { + const context = start(host, directory) + if (!context) return 'unavailable' + const { isCurrent, targetExists } = context + try { + const database = isFormDirName(directory) + const title = database ? formTitleFromDir(directory) : directory.split('/').pop()! + const confirmed = await confirmApp({ + title: `Delete "${title}"?`, + description: database + ? 'All records will be permanently deleted. This cannot be undone.' + : 'Everything inside will be permanently deleted. This cannot be undone.', + confirmLabel: 'Delete', + danger: true + }) + if (!confirmed) return 'cancelled' + if (!isCurrent() || !targetExists()) return 'stale' + await useStore.getState().deleteFolder('inbox', directory, isCurrent) + return isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} + +/** Omit directory for configured placement; an explicit directory is primary-relative. */ +export async function createBrowseDatabase( + host: BrowseActionHost, + directory?: string +): Promise { + const state = useStore.getState() + const target = + directory === undefined + ? resolveCreateLocation( + state.vaultSettings.databasesLocation, + state.activeNote, + state.vaultSettings + ) + : { folder: 'inbox' as const, subpath: directory } + if (directory !== undefined && formDirContaining(target.subpath)) return 'unavailable' + const context = start(host, directory ?? '', true) + if (!context) return 'unavailable' + try { + await useStore + .getState() + .createDatabase(target.folder, target.subpath, undefined, context.isCurrent) + return context.isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} + +/** Rename a database by its Browse directory, retaining the host's collision behavior. */ +export async function requestRenameBrowseDatabase( + host: BrowseActionHost, + directory: string +): Promise { + if (!isFormDirName(directory)) return 'unavailable' + const context = start(host, directory) + if (!context) return 'unavailable' + const { isCurrent, targetExists } = context + try { + const title = formTitleFromDir(directory) + const validate = (value: string): string | null => + value.trim().startsWith('.') ? 'Database names cannot start with a dot.' : + !value.trim() || /[\\/\u0000-\u001f]/.test(value) + ? 'Enter a database name without / or \\.' + : null + const name = ( + await promptApp({ + title: 'Rename database', + initialValue: title, + okLabel: 'Rename', + validate + }) + )?.trim() + if (!name || name === title || validate(name)) return 'cancelled' + if (!isCurrent() || !targetExists()) return 'stale' + const primary = primaryDirectory() + const csv = csvPathForFormDir(primary ? `${primary}/${directory}` : directory) + await useStore.getState().renameDatabase(csv, name, isCurrent) + return isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} diff --git a/packages/app-core/src/lib/cloud-auto-sync.test.ts b/packages/app-core/src/lib/cloud-auto-sync.test.ts index 355b5fb2..c05d1126 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.test.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.test.ts @@ -262,6 +262,26 @@ describe("cloud auto sync host wiring", () => { } finally { unregister(); runtime.stop(); } }); + it("clears the stale success state when the metadata probe discovers an unlinked vault", async () => { + const host = setup(); + const missing = new Error("This Cloud vault is no longer available. Your local notes are unchanged."); + const probe = vi.fn(async () => { host.setLinked(false); throw missing; }); + const runtime = startCloudAutoSync({ ...host.bridge, hasCloudVaultChanges: probe }, host.environment, { + intervalMs: 60_000, onError: vi.fn(), + }); + try { + await vi.advanceTimersByTimeAsync(1); + expect(useCloudSyncStatusStore.getState().lastSummary).not.toBeNull(); + await vi.advanceTimersByTimeAsync(5_000); + expect(probe).toHaveBeenCalledOnce(); + expect(useCloudSyncStatusStore.getState()).toMatchObject({ + phase: "unlinked", vaultName: null, lastSummary: null, lastSyncedAt: null, + conflictReviewOpen: false, error: missing.message, + }); + expect(host.logoutCloudAccount).not.toHaveBeenCalled(); + } finally { runtime.stop(); } + }); + it("syncs at startup and debounces syncable vault changes", async () => { const host = setup(); const runtime = startCloudAutoSync(host.bridge, host.environment, { diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index 6aecc206..5f06c626 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -1,3 +1,4 @@ +import { humanIpcError } from "./ipc-error"; import type { ZenBridge } from "@zennotes/bridge-contract/bridge"; import { getZenBridge } from "@zennotes/bridge-contract/bridge"; import type { CloudSyncRunSummary } from "@zennotes/bridge-contract/cloud-sync"; @@ -145,7 +146,12 @@ export function startCloudAutoSync( ? async () => { const state = useCloudSyncStatusStore.getState(); if (!state.vaultName || !isCloudAccountConnectedPhase(state.phase)) return false; - return bridge.hasCloudVaultChanges!(); + try { + return await bridge.hasCloudVaultChanges!(); + } catch (error) { + await refreshRemovedCloudLink(bridge, error); + throw error; + } } : undefined, online: environment.online, @@ -179,6 +185,7 @@ export function startCloudAutoSync( phase: "error", error: syncFailureMessage(error), }); + void refreshRemovedCloudLink(bridge, error); } useCloudSyncStatusStore.setState({ syncWindowLocked: false }); }, @@ -231,7 +238,7 @@ export async function connectCloudAccountFromStatusBar( } export async function syncCloudVaultWithStatus( - bridge: Pick = getZenBridge(), + bridge: Pick & Partial> = getZenBridge(), vaultName?: string | null, ): Promise { const current = useCloudSyncStatusStore.getState(); @@ -250,15 +257,31 @@ export async function syncCloudVaultWithStatus( applyCloudSyncSummary(summary, nextVaultName); return summary; } catch (error) { - useCloudSyncStatusStore.setState({ - phase: "error", - vaultName: nextVaultName, - error: syncFailureMessage(error), - }); + if (!await refreshRemovedCloudLink(bridge, error)) { + useCloudSyncStatusStore.setState({ + phase: "error", + vaultName: nextVaultName, + error: syncFailureMessage(error), + }); + } throw error; } } +async function refreshRemovedCloudLink( + bridge: Partial>, + error: unknown, +): Promise { + if (!bridge.getCloudVaultLink) return false; + try { + if (await bridge.getCloudVaultLink() !== null) return false; + } catch { + return false; + } + markCloudSyncUnlinked(syncFailureMessage(error)); + return true; +} + /** Retire only the acknowledged decision, not the status of the whole vault. */ export function acknowledgeCloudConflictResolution( conflictId: string, @@ -381,13 +404,15 @@ function markCloudSyncConnecting(): void { }); } -function markCloudSyncUnlinked(): void { +function markCloudSyncUnlinked(error: string | null = null): void { useCloudSyncStatusStore.setState({ phase: "unlinked", vaultName: null, lastSyncedAt: null, resolutionSaved: false, - error: null, + error, + lastSummary: null, + conflictReviewOpen: false, }); } @@ -442,7 +467,7 @@ function syncFailureMessage(error: unknown): string { } function cloudSyncErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); + return humanIpcError(error instanceof Error ? error : new Error(String(error)), "Cloud sync failed."); } export function cloudSyncAttentionMessage( diff --git a/packages/app-core/src/lib/cm-vim-clipboard.ts b/packages/app-core/src/lib/cm-vim-clipboard.ts index 8eb23e26..ebb41527 100644 --- a/packages/app-core/src/lib/cm-vim-clipboard.ts +++ b/packages/app-core/src/lib/cm-vim-clipboard.ts @@ -14,6 +14,7 @@ * on yank so the active view can flash the yanked range. */ import { ViewPlugin, type EditorView } from '@codemirror/view' +import type { Extension } from '@codemirror/state' import { Vim, getCM } from '@replit/codemirror-vim' interface PatchableRegisterController { @@ -101,7 +102,7 @@ export function setPasteFromClipboardEnabled(on: boolean): void { * hand the key back to Vim so all of its paste behaviour (linewise handling, * counts, visual-mode replace) runs unchanged. */ -export const vimClipboardPasteExtension = ViewPlugin.fromClass( +export const vimClipboardPasteExtension: Extension = ViewPlugin.fromClass( class { private readonly view: EditorView private readonly onKeyDown: (e: KeyboardEvent) => void diff --git a/packages/app-core/src/lib/cm-vim-visual-block.test.ts b/packages/app-core/src/lib/cm-vim-visual-block.test.ts new file mode 100644 index 00000000..fbc052a4 --- /dev/null +++ b/packages/app-core/src/lib/cm-vim-visual-block.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { EditorState } from '@codemirror/state' +import { EditorView, keymap } from '@codemirror/view' +import { getCM, Vim, vim } from '@replit/codemirror-vim' +import { afterEach, describe, expect, it } from 'vitest' +import { registerDisplayLineMotion } from './cm-vim-display-line' +import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from './cm-vim-default-keymap' +import { vimVisualHighlightExtension } from './cm-vim-visual-highlight' +import { vimClipboardPasteExtension } from './cm-vim-clipboard' + +const views: EditorView[] = [] + +afterEach(() => { + views.splice(0).forEach((view) => view.destroy()) +}) + +function mount(doc: string, anchor = 0): EditorView { + registerDisplayLineMotion(() => 'logical') + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc, + selection: { anchor }, + extensions: [ + vim(), + vimVisualHighlightExtension, + vimClipboardPasteExtension, + markdown({ base: markdownLanguage, addKeymap: false }), + vimAwareMarkdownKeymap, + keymap.of(vimAwareDefaultKeymap(true)) + ] + }) + }) + views.push(view) + return view +} + +function press(view: EditorView, key: string, modifiers: KeyboardEventInit = {}): void { + view.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...modifiers }) + ) +} + +function selectFirstColumn(view: EditorView): void { + press(view, 'v', { ctrlKey: true }) + press(view, 'j') + press(view, 'j') + expect(getCM(view)?.state.vim?.visualBlock).toBe(true) +} + +function insertText(view: EditorView, text: string): void { + expect(getCM(view)?.state.vim?.insertMode).toBe(true) + // jsdom does not type into contenteditable. Apply the ordinary CM6 text + // input transaction so the real Vim adapter observes the insertion. + view.dispatch({ ...view.state.replaceSelection(text), userEvent: 'input.type' }) + press(view, 'Escape') +} + +describe('Vim visual-block editing (#792)', () => { + it('prefixes every selected row with I, typed text, and Escape', () => { + const view = mount('one\ntwo\nthree') + selectFirstColumn(view) + + press(view, 'I') + insertText(view, '- ') + + expect(view.state.doc.toString()).toBe('- one\n- two\n- three') + expect(getCM(view)?.state.vim?.insertMode).toBe(false) + }) + + it('appends at the selected block edge with A instead of the logical line end', () => { + const view = mount('one\ntwo\nthree') + selectFirstColumn(view) + + press(view, 'A') + insertText(view, '!') + + expect(view.state.doc.toString()).toBe('o!ne\nt!wo\nt!hree') + }) + + it('prefixes every row when the block was selected from bottom to top', () => { + const view = mount('one\ntwo\nthree', 8) + press(view, 'v', { ctrlKey: true }) + press(view, 'k') + press(view, 'k') + + press(view, 'I') + insertText(view, '- ') + + expect(view.state.doc.toString()).toBe('- one\n- two\n- three') + }) + + it('deletes the selected columns on every row without joining the lines', () => { + const view = mount('one\ntwo\nthree') + selectFirstColumn(view) + + press(view, 'd') + + expect(view.state.doc.toString()).toBe('ne\nwo\nhree') + }) + + it('changes the selected column on every row and returns to normal mode', () => { + const view = mount('one\ntwo\nthree') + selectFirstColumn(view) + + press(view, 'c') + insertText(view, 'X') + + expect(view.state.doc.toString()).toBe('Xne\nXwo\nXhree') + expect(getCM(view)?.state.vim?.insertMode).toBe(false) + }) + + it('yanks the whole rectangle into a blockwise register without editing the note', () => { + const view = mount('one\ntwo\nthree') + selectFirstColumn(view) + + press(view, '"') + press(view, 'a') + press(view, 'y') + + const register = Vim.getRegisterController().getRegister('a') + expect(register.toString()).toBe('o\nt\nt') + expect(register.blockwise).toBe(true) + expect(view.state.doc.toString()).toBe('one\ntwo\nthree') + }) + + it('pastes a yanked rectangle as one inserted column per destination row', () => { + const view = mount('one\ntwo\nthree\n---\n...\n...') + selectFirstColumn(view) + for (const key of ['"', 'a', 'y', '3', 'j', '"', 'a', 'P']) press(view, key) + + expect(view.state.doc.toString()).toBe('one\ntwo\nthree\no---\nt...\nt...') + }) + + it('leaves shorter rows and their newlines intact when deleting a later column', () => { + const view = mount('abcd\nx\nwxyz', 2) + press(view, 'v', { ctrlKey: true }) + press(view, '2') + press(view, 'j') + + press(view, 'd') + + expect(view.state.doc.toString()).toBe('abd\nx\nwxz') + }) +}) + +describe('Vim line-boundary insertion outside visual mode', () => { + it.each([ + ['I', ' !one\ntwo'], + ['A', ' one!\ntwo'] + ])('keeps normal-mode %s on the current logical line', (key, expected) => { + const view = mount(' one\ntwo', 3) + + press(view, key) + insertText(view, '!') + + expect(view.state.doc.toString()).toBe(expected) + }) +}) diff --git a/packages/app-core/src/lib/cm-vim-visual-highlight.ts b/packages/app-core/src/lib/cm-vim-visual-highlight.ts index 92953f5c..db729379 100644 --- a/packages/app-core/src/lib/cm-vim-visual-highlight.ts +++ b/packages/app-core/src/lib/cm-vim-visual-highlight.ts @@ -9,7 +9,7 @@ * painted with mark decorations instead, which hug the glyphs of every * wrapped row by construction. Inert with Vim off or outside visual mode. */ -import { RangeSetBuilder, type Text } from '@codemirror/state' +import { EditorState, RangeSetBuilder, type Text } from '@codemirror/state' import { Decoration, type DecorationSet, @@ -44,9 +44,9 @@ function vimInVisualMode(view: EditorView): boolean { } /** - * CodeMirror-Vim keeps a block's full rectangle in `vim.sel`, but mirrors only - * the head row into CM6's EditorSelection. Build one inclusive text range per - * logical row so the custom highlighter paints the complete rectangle. + * CodeMirror-Vim keeps a block's full rectangle in `vim.sel`. Build one + * inclusive text range per logical row so the custom highlighter paints the + * complete rectangle, including when short rows clip the native selections. */ export function visualBlockMarkRanges( doc: Text, @@ -109,4 +109,10 @@ const visualClassAttribute = EditorView.editorAttributes.of((view) => vimInVisualMode(view) ? { class: 'zen-vim-visual-active' } : null ) -export const vimVisualHighlightExtension = [visualSelectionPlugin, visualClassAttribute] +export const vimVisualHighlightExtension = [ + // Vim block edits use one CM6 selection per row. Without this facet, CM6 + // silently keeps only the primary row even when we paint the full rectangle. + EditorState.allowMultipleSelections.of(true), + visualSelectionPlugin, + visualClassAttribute +] diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index a9eeb6cd..a1c8d348 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -9,6 +9,7 @@ import { isTagsViewActive, isTasksViewActive, isTrashViewActive, useStore } from '../store' import { confirmApp } from './confirm-requests' import { promptApp } from './prompt-requests' +import { captureNavigationContext } from './navigation-context' import { buildMoveNotePrompt, parseMoveNoteTarget } from './move-note' import { focusPaneInDirection } from './pane-nav' import { focusSidebarPanel } from './sidebar-focus' @@ -314,6 +315,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Note', when: () => !!getState().activeNote, run: async () => { + const isCurrent = captureNavigationContext() const active = getState().activeNote if (!active) return const next = await promptApp({ @@ -321,7 +323,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma initialValue: active.title, okLabel: 'Rename' }) - if (next && next !== active.title) await getState().renameActive(next) + if (next && next !== active.title && isCurrent() && getState().selectedPath === active.path) + await getState().renameActive(next) } }, { @@ -524,13 +527,14 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'move mv relocate folder archive inbox', when: () => !!getState().activeNote, run: async () => { + const isCurrent = captureNavigationContext() const state = getState() const active = state.activeNote if (!active) return const target = await promptApp(buildMoveNotePrompt(active, state.folders)) - if (!target) return + if (!target || !isCurrent()) return const dest = parseMoveNoteTarget(target) - await state.moveNote(active.path, dest.folder, dest.subpath) + await state.moveNote(active.path, dest.folder, dest.subpath, isCurrent) } } ) diff --git a/packages/app-core/src/lib/confirm-trash.ts b/packages/app-core/src/lib/confirm-trash.ts index d1e1bb22..9718058e 100644 --- a/packages/app-core/src/lib/confirm-trash.ts +++ b/packages/app-core/src/lib/confirm-trash.ts @@ -1,11 +1,13 @@ import { confirmApp } from './confirm-requests' -export function confirmMoveToTrash(title?: string | null): Promise { +export function confirmMoveToTrash(title?: string | null, systemTrash = false): Promise { const trimmed = title?.trim() const target = trimmed ? `"${trimmed}"` : 'this note' return confirmApp({ title: `Move ${target} to Trash?`, - description: 'You can restore it later from the Trash view.', + description: systemTrash + ? 'The file will move to your system Trash. Restore it using your file manager.' + : 'You can restore it later from the Trash view.', confirmLabel: 'Move to Trash' }) } diff --git a/packages/app-core/src/lib/editor-commands.ts b/packages/app-core/src/lib/editor-commands.ts new file mode 100644 index 00000000..77e635c9 --- /dev/null +++ b/packages/app-core/src/lib/editor-commands.ts @@ -0,0 +1,104 @@ +import { indentLess, indentMore, redo, undo } from '@codemirror/commands' +import { closeSearchPanel, openSearchPanel } from '@codemirror/search' +import type { EditorView } from '@codemirror/view' +import type { EditorCommand } from '../editor' +import { setBlockType, toggleWrap, wrapLink, type BlockType } from './cm-format' + +const BLANK_LINE_MARKERS: Partial> = { + bullet: '- ', + todo: '- [ ] ', + h1: '# ', + h2: '## ', + h3: '### ' +} + +// A mobile toolbar is also how users start an empty list. The selection toolbar's +// conversion helper intentionally skips blank lines, so retain this shell behavior. +function applyBlockType(view: EditorView, type: BlockType): boolean { + const { from, to } = view.state.selection.main + const line = view.state.doc.lineAt(from) + const marker = BLANK_LINE_MARKERS[type] + if (marker !== undefined && from === to && line.text.trim() === '') { + const insert = line.text + marker + view.dispatch({ + changes: { from: line.from, to: line.to, insert }, + selection: { anchor: line.from + insert.length } + }) + return true + } + return setBlockType(view, type) +} + +function cycleHeading(view: EditorView): boolean { + const line = view.state.doc.lineAt(view.state.selection.main.from) + const level = line.text.match(/^(#{1,6})\s/)?.[1].length ?? 0 + const next = level >= 3 ? 'paragraph' : (['h1', 'h2', 'h3'] as const)[level]! + return applyBlockType(view, next) +} + +function insertSnippet(view: EditorView, text: string, caretOffset: number): boolean { + const { from, to } = view.state.selection.main + view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + caretOffset } }) + return true +} + +export function runNoteEditorCommand(view: EditorView, command: EditorCommand): boolean { + // Search owns its focus. Refocusing the editor here would dismiss the native + // keyboard's query target immediately after the host opens Find. + if (command === 'open-search') return openSearchPanel(view) + if (command === 'close-search') return closeSearchPanel(view) + if (view.state.readOnly) return false + + let handled: boolean + switch (command) { + case 'undo': + handled = undo(view) + break + case 'redo': + handled = redo(view) + break + case 'indent': + handled = indentMore(view) + break + case 'outdent': + handled = indentLess(view) + break + case 'toggle-bold': + handled = toggleWrap(view, '**') + break + case 'toggle-italic': + handled = toggleWrap(view, '*') + break + case 'toggle-strikethrough': + handled = toggleWrap(view, '~~') + break + case 'toggle-highlight': + handled = toggleWrap(view, '==') + break + case 'toggle-inline-code': + handled = toggleWrap(view, '`') + break + case 'insert-link': + handled = wrapLink(view) + break + case 'insert-wikilink': + handled = insertSnippet(view, '[[]]', 2) + break + case 'insert-tag': + handled = insertSnippet(view, '#', 1) + break + case 'set-bullet-list': + handled = applyBlockType(view, 'bullet') + break + case 'set-task-list': + handled = applyBlockType(view, 'todo') + break + case 'cycle-heading': + handled = cycleHeading(view) + break + default: + return false + } + view.focus() + return handled +} diff --git a/packages/app-core/src/lib/editor-host.ts b/packages/app-core/src/lib/editor-host.ts new file mode 100644 index 00000000..30124593 --- /dev/null +++ b/packages/app-core/src/lib/editor-host.ts @@ -0,0 +1,168 @@ +import { Compartment, type Extension } from '@codemirror/state' +import { EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view' +import type { EditorBounds, EditorHostOptions, EditorHostRegistration } from '../editor' + +const hostCompartment = new Compartment() +const mounted = new Set() +let active: { options: EditorHostOptions } | null = null +const insetProperty = '--zen-editor-host-bottom-inset' +const nativeTyping = { + autocorrect: 'on', + autocapitalize: 'sentences', + spellcheck: 'true', + writingsuggestions: 'true' +} +const insetTheme = EditorView.theme({ + '&': { boxSizing: 'border-box', paddingBottom: `var(${insetProperty}, 0px)` } +}) + +function bounds(rect: DOMRect): EditorBounds { + const { top, bottom, left, right, width, height } = rect + return Object.freeze({ top, bottom, left, right, width, height }) +} + +function inset(value: number | undefined, height: number): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.min(value, height)) + : 0 +} + +class HostView { + scrollBottom = 0 + private destroyed = false + private reveal: (() => boolean) | null = null + + constructor(readonly view: EditorView) { + mounted.add(this) + if (active?.options.measureBottomInsets) this.refresh() + } + + update(update: ViewUpdate): void { + if (active?.options.measureBottomInsets && (update.geometryChanged || update.viewportChanged)) + this.refresh() + } + + refresh(): void { + if (this.destroyed) return + this.view.requestMeasure({ + key: this, + read: () => { + const owner = active + if (this.destroyed) return { owner, layout: 0, scroll: 0 } + const editor = bounds(this.view.dom.getBoundingClientRect()) + const scroll = bounds(this.view.scrollDOM.getBoundingClientRect()) + try { + const measured = owner?.options.measureBottomInsets?.(Object.freeze({ editor, scroll })) + return { + owner, + layout: inset(measured?.layout, editor.height), + scroll: inset(measured?.scroll, scroll.height) + } + } catch { + // Host overlays can disappear during disposal. Do not retain stale clearance. + return { owner, layout: 0, scroll: 0 } + } + }, + write: (measured) => { + if (this.destroyed || active !== measured.owner) return + this.scrollBottom = measured.scroll + const value = active?.options.measureBottomInsets ? `${measured.layout}px` : '' + if (this.view.dom.style.getPropertyValue(insetProperty) !== value) { + if (value) this.view.dom.style.setProperty(insetProperty, value) + else this.view.dom.style.removeProperty(insetProperty) + // Layout clearance changes the scroll viewport. Measure again before + // applying its additional margin or revealing the caret. + this.refresh() + return + } + const reveal = this.reveal + if (!reveal) return + // CodeMirror forbids dispatch during the measurement write phase. + queueMicrotask(() => { + if (this.destroyed || active !== measured.owner || this.reveal !== reveal) return + this.reveal = null + if (reveal()) + this.view.dispatch({ + effects: EditorView.scrollIntoView(this.view.state.selection.main.head, { + y: 'nearest' + }) + }) + }) + } + }) + } + + requestReveal(isCurrent: () => boolean): void { + this.reveal = isCurrent + this.refresh() + } + + reset(): void { + this.reveal = null + this.scrollBottom = 0 + this.view.dom.style.removeProperty(insetProperty) + } + + destroy(): void { + this.destroyed = true + this.reset() + mounted.delete(this) + } +} + +const hostPlugin = ViewPlugin.fromClass(HostView) + +function configuration(): Extension { + return [ + active?.options.nativeTyping ? EditorView.contentAttributes.of(nativeTyping) : [], + active?.options.measureBottomInsets + ? [ + insetTheme, + EditorView.scrollMargins.of((view) => ({ + bottom: view.plugin(hostPlugin)?.scrollBottom ?? 0 + })) + ] + : [] + ] +} + +function reconfigure(): void { + for (const host of mounted) { + host.reset() + host.view.dispatch({ effects: hostCompartment.reconfigure(configuration()) }) + if (active?.options.measureBottomInsets) host.refresh() + } +} + +/** Part of the editor's initial state, before publication or a first focus. */ +export function noteEditorHostExtension(): Extension { + return [hostCompartment.of(configuration()), hostPlugin] +} + +export function installNoteEditorHost(options: EditorHostOptions): EditorHostRegistration { + const owner = { + options: { + nativeTyping: options.nativeTyping, + measureBottomInsets: options.measureBottomInsets + } + } + active = owner + reconfigure() + return { + refresh: () => { + if (active === owner) for (const host of mounted) host.refresh() + }, + dispose: () => { + if (active !== owner) return + active = null + reconfigure() + } + } +} + +export function requestNoteEditorReveal(view: EditorView, isCurrent: () => boolean): boolean { + const host = view.plugin(hostPlugin) + if (!host) return false + host.requestReveal(isCurrent) + return true +} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index f321cd0d..5d507f17 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1144,7 +1144,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { title: 'CLI', items: [ - { label: 'Install Command-Line Tool', detail: 'Symlink the bundled `zn` wrapper into a usable PATH location so any terminal session can capture, search, and edit notes. ZenNotes prefers user-writable directories and only prompts for admin access when no writable PATH target is available. The CLI runtime stays packaged with the app, including the dependencies needed by `zn mcp`, so updates ship together.' }, + { label: 'Install Command-Line Tool', detail: 'Symlink the bundled `zn` wrapper into a usable PATH location so any terminal session can capture, search, and edit notes. ZenNotes prefers user-writable directories and only prompts for admin access when no writable PATH target is available. The app carries the tested CLI runtime, so desktop-managed updates ship together. Builds with the terminal app install a persistent copy that also works after desktop closes.' }, { label: 'Status, path, and quick reference', detail: 'Settings → CLI shows whether `zn` is installed, where the symlink lives, and a copy-able quick reference of the most useful commands. If the chosen directory is not on PATH yet, Settings shows the exact shell command to add it. An "External install" badge appears when something else owns `zn` so ZenNotes never clobbers an unmanaged binary.' }, { label: 'Paths with spaces', detail: 'Quote note paths like `zn read "hellointerview/system design.md"` or pass them with `--path "hellointerview/system design.md"` so your shell keeps the path as one argument.' }, { label: 'Raycast on macOS', detail: 'The Raycast extension requires `zn` and can be installed locally from this settings page. ZenNotes copies the bundled extension into app data, installs dependencies, builds it, and imports it into Raycast. It searches with `zn list --json`, then opens notes in ZenNotes through `zennotes://open` or `zennotes://open-window` and exposes archive, unarchive, trash, reveal, copy path, and copy wikilink actions from Raycast.' }, @@ -1193,6 +1193,11 @@ export const HELP_CLI: HelpCard[] = [ body: 'Open Settings → CLI and click Install. ZenNotes symlinks the bundled wrapper into a usable PATH location, preferring user-writable directories and only asking for admin access when no writable PATH target is available. After that, `zn --help` works in any new terminal. You can also run the install from the command palette via "Install Command-Line Tool (zn)".' }, + { + title: 'The terminal app and existing installations', + body: + 'Builds with the Go terminal tool include `zn tui`. Update and open ZenNotes once to upgrade an existing desktop-managed CLI; keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' + }, { title: 'No app required', body: diff --git a/packages/app-core/src/lib/navigation-context.ts b/packages/app-core/src/lib/navigation-context.ts new file mode 100644 index 00000000..08f0afc8 --- /dev/null +++ b/packages/app-core/src/lib/navigation-context.ts @@ -0,0 +1,17 @@ +import { useStore } from '../store' +import { isWorkspaceTransitionPending, workspaceGeneration } from './workspace-transition' + +/** A relative path is meaningful only in the workspace where navigation began. */ +export function captureNavigationContext(): () => boolean { + const { vault, workspaceMode, remoteWorkspaceInfo } = useStore.getState() + const bridge = window.zen + const generation = workspaceGeneration() + const startedDuringTransition = isWorkspaceTransitionPending() + return () => { + const state = useStore.getState() + return !startedDuringTransition && !isWorkspaceTransitionPending() + && generation === workspaceGeneration() && state.vault === vault && window.zen === bridge + && state.workspaceMode === workspaceMode && state.remoteWorkspaceInfo?.baseUrl === remoteWorkspaceInfo?.baseUrl + && state.remoteWorkspaceInfo?.profileId === remoteWorkspaceInfo?.profileId + } +} diff --git a/packages/app-core/src/lib/note-editor-context.ts b/packages/app-core/src/lib/note-editor-context.ts new file mode 100644 index 00000000..3803a243 --- /dev/null +++ b/packages/app-core/src/lib/note-editor-context.ts @@ -0,0 +1,14 @@ +import type { EditorView } from '@codemirror/view' + +// A selected path can change before React updates the editor. Keep the view's +// actual path available to integrations without publishing the view itself. +const noteEditors = new WeakMap string | null; paneId: string }>() + +export function registerNoteEditor(view: EditorView, path: () => string | null, paneId: string): void { + noteEditors.set(view, { path, paneId }) +} + +export function noteEditorMatches(view: EditorView, path: string, paneId: string): boolean { + const registered = noteEditors.get(view) + return registered?.paneId === paneId && registered.path() === path +} diff --git a/packages/app-core/src/lib/note-lifecycle-actions.ts b/packages/app-core/src/lib/note-lifecycle-actions.ts new file mode 100644 index 00000000..5ed651a9 --- /dev/null +++ b/packages/app-core/src/lib/note-lifecycle-actions.ts @@ -0,0 +1,54 @@ +import { + requestArchiveNote, + requestNoteBatch, + requestEmptyTrash, + type NoteBatchAction, + requestDeleteNotePermanently, + requestTrashNote, + restoreNote, + type NoteActionResult, +} from "../notes"; +import { humanIpcError } from "./ipc-error"; +import { useToastStore } from "./toast"; + +/** Core UI uses the same guarded actions as an installed native host. */ +export async function runNoteLifecycleAction( + path: string, + action: "archive" | "trash" | "restore" | "delete", +): Promise { + const actions = { + archive: requestArchiveNote, + trash: requestTrashNote, + restore: restoreNote, + delete: requestDeleteNotePermanently, + }; + try { + // The public action captures the core vault, bridge, and folder layout. + return await actions[action]({ isCurrent: () => true }, path); + } catch (error) { + useToastStore + .getState() + .addToast( + humanIpcError(error, "Could not complete the note action."), + "error", + ); + return "failed"; + } +} + + +export async function runNoteBatchAction(paths: readonly string[], action: NoteBatchAction): Promise { + try { + const result = await requestNoteBatch({isCurrent:()=>true}, paths, action) + if (result.status === 'stale') useToastStore.getState().addToast('The workspace changed. Check completed note actions before retrying.', 'info') + return result.status === 'completed' + } catch (error) { + useToastStore.getState().addToast(humanIpcError(error,'Some notes could not be changed.'),'error') + return false + } +} + +export async function runEmptyTrash(): Promise { + try {await requestEmptyTrash({isCurrent:()=>true})} + catch(error){useToastStore.getState().addToast(humanIpcError(error,'Could not empty Trash.'),'error')} +} diff --git a/packages/app-core/src/lib/note-lifecycle-lock.ts b/packages/app-core/src/lib/note-lifecycle-lock.ts new file mode 100644 index 00000000..76aa9146 --- /dev/null +++ b/packages/app-core/src/lib/note-lifecycle-lock.ts @@ -0,0 +1,105 @@ +import { + Annotation, + Compartment, + EditorState, + Prec, + type Extension, +} from "@codemirror/state"; +import { EditorView, ViewPlugin } from "@codemirror/view"; + +export const noteEditingSync = Annotation.define(); + +type Target = { vault: object | null; path: string | null }; +const locks = new WeakMap>(); +const listeners = new Set<() => void>(); +export function subscribeNoteEditingLocks(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} +function notifyLocks(): void { for (const listener of listeners) listener(); } +const contains = (scope: string | null, path: string | null) => + scope === null || (path !== null && (scope === path || (scope.endsWith("/") && path.startsWith(scope)))); +const editors = new Map< + EditorView, + { target: () => Target; refresh: () => void } +>(); + +export function isNoteEditingLocked( + vault: object | null, + path: string | null, +): boolean { + return !!(vault && path && [...(locks.get(vault) ?? [])].some(scope => contains(scope, path))); +} + +/** Hold only for operations that leave no editable destination in the vault. */ +export function lockNoteEditing(vault: object, path: string): () => void { + return acquireEditingLock(vault, path); +} + +/** Freeze every current and subsequently mounted editor in this vault. */ +export function lockVaultEditing(vault: object): () => void { + return acquireEditingLock(vault, null); +} + +function acquireEditingLock(vault: object, path: string | null): () => void { + for (const [view, editor] of editors) { + const target = editor.target(); + if (target.vault === vault && target.path && contains(path, target.path) && view.composing) + throw new Error("Finish entering text before changing this note or vault."); + } + const paths = locks.get(vault) ?? new Set(); + if ([...paths].some(scope => contains(scope, path) || contains(path, scope))) throw new Error("This note is already being deleted."); + paths.add(path); + locks.set(vault, paths); + notifyLocks(); + for (const editor of editors.values()) editor.refresh(); + return () => { + paths.delete(path); + notifyLocks(); + for (const editor of editors.values()) editor.refresh(); + }; +} + +export function refreshNoteEditingLock(view: EditorView): void { + editors.get(view)?.refresh(); +} + +export function noteEditingLockExtension(target: () => Target): Extension { + const compartment = new Compartment(); + const locked = () => { + const { vault, path } = target(); + return isNoteEditingLocked(vault, path); + }; + const configuration = () => + locked() + ? Prec.highest([ + EditorState.readOnly.of(true), + EditorView.editable.of(false), + ]) + : []; + return [ + compartment.of(configuration()), + // Consult the live lock even before a view can apply its reconfiguration. + EditorState.changeFilter.of( + (transaction) => + transaction.annotation(noteEditingSync) === true || !locked(), + ), + ViewPlugin.define((view) => { + let previous = locked(); + editors.set(view, { + target, + refresh: () => { + const next = locked(); + if (next === previous) return; + previous = next; + view.dispatch({ effects: compartment.reconfigure(configuration()) }); + }, + }); + return { + destroy: () => { + editors.delete(view); + }, + }; + }), + ]; +} diff --git a/packages/app-core/src/lib/note-order.ts b/packages/app-core/src/lib/note-order.ts new file mode 100644 index 00000000..c2fa8eee --- /dev/null +++ b/packages/app-core/src/lib/note-order.ts @@ -0,0 +1,37 @@ +import { naturalCompare } from './natural-sort' + +export type NoteSortOrder = + | 'none' + | 'manual' + | 'updated-desc' + | 'updated-asc' + | 'created-desc' + | 'created-asc' + | 'name-asc' + | 'name-desc' + +interface SortableNote { + readonly title: string + readonly updatedAt: number + readonly createdAt: number +} + +/** Mobile Browse has no manual drag order; none/manual retain its recent-first fallback. */ +export function browseNoteComparator( + order: NoteSortOrder +): (a: SortableNote, b: SortableNote) => number { + switch (order) { + case 'name-asc': + return (a, b) => naturalCompare(a.title, b.title) + case 'name-desc': + return (a, b) => naturalCompare(b.title, a.title) + case 'updated-asc': + return (a, b) => a.updatedAt - b.updatedAt + case 'created-desc': + return (a, b) => b.createdAt - a.createdAt + case 'created-asc': + return (a, b) => a.createdAt - b.createdAt + default: + return (a, b) => b.updatedAt - a.updatedAt + } +} diff --git a/packages/app-core/src/lib/settings-navigation.ts b/packages/app-core/src/lib/settings-navigation.ts index a4f35a25..25252575 100644 --- a/packages/app-core/src/lib/settings-navigation.ts +++ b/packages/app-core/src/lib/settings-navigation.ts @@ -1,7 +1,7 @@ /** Settings pages other surfaces can open directly: the Cloud page from the * status bar, the Keymaps page from `:unbind` when the action id is missing * or unknown. */ -export type SettingsNavigationTarget = "cloud" | "keymaps" | "external-links"; +export type SettingsNavigationTarget = "cloud" | "keymaps" | "external-links" | "about"; let pendingSettingsTarget: SettingsNavigationTarget | null = null; diff --git a/packages/app-core/src/lib/tags.ts b/packages/app-core/src/lib/tags.ts index f8830278..ea8ae6f5 100644 --- a/packages/app-core/src/lib/tags.ts +++ b/packages/app-core/src/lib/tags.ts @@ -37,7 +37,7 @@ export function extractTags(body: string): string[] { * tag. Fence detection is line-based and indentation-tolerant: a fence nested * under a list item is still a code block, so e.g. a C `#include` line inside * it is not a tag (#293). Mirrors `stripCodeContent` in - * apps/desktop/src/main/vault.ts and apps/server/internal/vault/parse.go — + * apps/desktop/src/main/vault.ts and internal/vault/parse.go in ZenNotes/znserver — * keep the three in sync. */ function stripCodeContent(body: string): string { diff --git a/packages/app-core/src/lib/task-column-mutations.ts b/packages/app-core/src/lib/task-column-mutations.ts new file mode 100644 index 00000000..d6cfde82 --- /dev/null +++ b/packages/app-core/src/lib/task-column-mutations.ts @@ -0,0 +1,76 @@ +import type { KanbanGroupBy, TaskMutation } from '../store' +import { toIsoDateLocal, type VaultTask } from '@shared/tasks' + +/** Map a (groupBy, columnId) drop target to the task-line mutations + * that should land. Returns `null` when the drop has no defined + * semantics (e.g. when group-by is 'folder'). Returns `[]` when the + * task is already in the target column; caller can short-circuit. */ +export function dropMutationsFor( + groupBy: KanbanGroupBy, + columnId: string, + task: VaultTask, + today: Date +): TaskMutation[] | null { + if (groupBy === 'status') { + const todayIso = toIsoDateLocal(today) + switch (columnId) { + case 'today': + // "Live" columns; make sure neither @waiting, [x] nor [/] keep the + // task glued to a different bucket. + return [ + { kind: 'set-checked', checked: false }, + { kind: 'set-waiting', waiting: false }, + { kind: 'set-in-progress', inProgress: false }, + { kind: 'set-due', due: todayIso } + ] + case 'upcoming': { + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + return [ + { kind: 'set-checked', checked: false }, + { kind: 'set-waiting', waiting: false }, + { kind: 'set-in-progress', inProgress: false }, + { + kind: 'set-due', + due: task.due && task.due > todayIso ? task.due : toIsoDateLocal(tomorrow) + } + ] + } + case 'in-progress': + // Started work: `[/]`. The due date is left alone, so a card dragged + // back to Today or Upcoming keeps the date it had. + return [ + { kind: 'set-checked', checked: false }, + { kind: 'set-waiting', waiting: false }, + { kind: 'set-in-progress', inProgress: true } + ] + case 'waiting': + // `[/]` survives underneath on purpose: clearing the wait returns the + // card to In progress, where it came from. + return [ + { kind: 'set-checked', checked: false }, + { kind: 'set-waiting', waiting: true } + ] + case 'done': + return [{ kind: 'set-checked', checked: true }] + default: + return null + } + } + if (groupBy === 'priority') { + if (columnId === 'high') return [{ kind: 'set-priority', priority: 'high' }] + if (columnId === 'med') return [{ kind: 'set-priority', priority: 'med' }] + if (columnId === 'low') return [{ kind: 'set-priority', priority: 'low' }] + if (columnId === 'none') return [{ kind: 'set-priority', priority: null }] + return null + } + if (groupBy.startsWith('field:')) { + // Drop sets the `@:` token; the No- column clears it. + const key = groupBy.slice('field:'.length) + return [{ kind: 'set-field', key, value: columnId === '__none__' ? null : columnId }] + } + // Folder grouping is read-only; moving the task across folders + // means moving the source note, which the user does explicitly via + // the sidebar. + return null +} diff --git a/packages/app-core/src/lib/wikilink-navigation.test.ts b/packages/app-core/src/lib/wikilink-navigation.test.ts index c6eaa875..f7e9b60c 100644 --- a/packages/app-core/src/lib/wikilink-navigation.test.ts +++ b/packages/app-core/src/lib/wikilink-navigation.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { isSameFileHeadingLink, wikilinkHeadingAnchor } from './wikilinks' diff --git a/packages/app-core/src/lib/wikilink-navigation.ts b/packages/app-core/src/lib/wikilink-navigation.ts index 23b58583..b0b54acb 100644 --- a/packages/app-core/src/lib/wikilink-navigation.ts +++ b/packages/app-core/src/lib/wikilink-navigation.ts @@ -1,3 +1,4 @@ +import { captureNavigationContext } from './navigation-context' import { useStore } from '../store' import { findBlockAnchor } from './block-anchors' import { parseOutline } from './outline' @@ -27,8 +28,11 @@ export function openDatabaseFromWikilink(target: string): boolean { * when the heading isn't found. Shared by the editor's wikilink click and the * preview pane so `[[Doc#Heading]]` lands on the heading. (#196) */ -export async function openWikilinkHeading(path: string, headingAnchor: string): Promise { +export async function openWikilinkHeading(path: string, headingAnchor: string): Promise { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false const body = await noteBody(path) + if (!isCurrent()) return false const needle = headingAnchor.trim().toLowerCase() const heading = parseOutline(body).find((h) => h.text.trim().toLowerCase() === needle) if (heading) { @@ -36,6 +40,7 @@ export async function openWikilinkHeading(path: string, headingAnchor: string): } else { await useStore.getState().selectNote(path) } + return isCurrent() && useStore.getState().selectedPath === path } /** @@ -43,13 +48,17 @@ export async function openWikilinkHeading(path: string, headingAnchor: string): * twin of {@link openWikilinkHeading}, with the same fallback: an id the note * no longer carries opens the note at the top rather than going nowhere. (#601) */ -export async function openWikilinkBlock(path: string, blockAnchor: string): Promise { +export async function openWikilinkBlock(path: string, blockAnchor: string): Promise { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false const block = findBlockAnchor(await noteBody(path), blockAnchor) + if (!isCurrent()) return false if (block) { await useStore.getState().openNoteAtOffset(path, block.from, { scrollMode: 'start' }) } else { await useStore.getState().selectNote(path) } + return isCurrent() && useStore.getState().selectedPath === path } /** @@ -60,7 +69,9 @@ export async function openWikilinkBlock(path: string, blockAnchor: string): Prom * quietly opened the note and stopped there for as long as they did: adding an * anchor kind meant remembering six call sites. (#601) */ -export async function openWikilinkTarget(path: string, target: string): Promise { +export async function openWikilinkTarget(path: string, target: string): Promise { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false const heading = wikilinkHeadingAnchor(target) if (heading) return openWikilinkHeading(path, heading) @@ -68,6 +79,7 @@ export async function openWikilinkTarget(path: string, target: string): Promise< if (block) return openWikilinkBlock(path, block) await useStore.getState().selectNote(path) + return isCurrent() && useStore.getState().selectedPath === path } /** The note's body from the store, falling back to a read, then to empty. */ diff --git a/packages/app-core/src/lib/wikilinks.test.ts b/packages/app-core/src/lib/wikilinks.test.ts index dc28c1f8..acc6a666 100644 --- a/packages/app-core/src/lib/wikilinks.test.ts +++ b/packages/app-core/src/lib/wikilinks.test.ts @@ -244,3 +244,20 @@ describe('extractMarkdownLinkHrefs (#70dark)', () => { expect(extractMarkdownLinkHrefs(body)).toEqual(['Note.md', 'https://example.com']) }) }) + +describe('resolveWikilinkTarget trims slash runs without regex backtracking', () => { + it('resolves an explicit path wrapped in slashes', () => { + expect(resolveWikilinkTarget(notes, '/projects/Spec/')?.path).toBe('inbox/projects/Spec.md') + expect(resolveWikilinkTarget(notes, '///projects/Spec///')?.path).toBe('inbox/projects/Spec.md') + }) + + it('resolves a path suffix with trailing slashes', () => { + expect(resolveWikilinkTarget(notes, 'projects/Spec/')?.path).toBe('inbox/projects/Spec.md') + expect(resolveWikilinkTarget(notes, 'projects/Spec///')?.path).toBe('inbox/projects/Spec.md') + }) + + it('treats a target made only of slashes as unresolved', () => { + expect(resolveWikilinkTarget(notes, '///')).toBeNull() + expect(resolveWikilinkTarget(notes, '/'.repeat(20000))).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/wikilinks.ts b/packages/app-core/src/lib/wikilinks.ts index b66f38d8..b21cad79 100644 --- a/packages/app-core/src/lib/wikilinks.ts +++ b/packages/app-core/src/lib/wikilinks.ts @@ -9,7 +9,7 @@ const INVALID_NOTE_PATH_CHARS = /[\\:*?"<>|#^\[\]]/ * reads code as a link. Line-based and indentation-tolerant: a fence nested * under a list item is still a code block (#293). Mirrors `stripCodeContent` in * tags.ts, apps/desktop/src/main/vault.ts, apps/desktop/src/mcp/vault-ops.ts, - * and apps/server/internal/vault/parse.go — keep all five in sync. + * and internal/vault/parse.go in ZenNotes/znserver — keep all five in sync. */ function stripCodeContent(body: string): string { if (!body.includes('`') && !body.includes('~')) return body @@ -56,6 +56,17 @@ function normalizeForCompare(value: string): string { return value.trim().toLowerCase() } +// Trim leading and trailing slashes with a linear scan. The equivalent +// `/\/+$/` regex backtracks quadratically on a target made of many slashes, +// and wikilink targets come straight from note text. +function trimSlashes(value: string): string { + let start = 0 + let end = value.length + while (start < end && value.charCodeAt(start) === 47) start++ + while (end > start && value.charCodeAt(end - 1) === 47) end-- + return value.slice(start, end) +} + export function isPathLikeWikilinkTarget(target: string): boolean { const trimmed = target.trim() return trimmed.startsWith('/') || trimmed.includes('/') || /\.md$/i.test(trimmed) @@ -147,7 +158,7 @@ function resolveExplicitPath(notes: NoteRef[], target: string): NoteRef | null { const normalized = normalizeSlashes(target.trim()) if (!normalized) return null - const trimmed = stripMdExtension(normalized).replace(/^\/+/, '').replace(/\/+$/, '') + const trimmed = trimSlashes(stripMdExtension(normalized)) if (!trimmed) return null let relPath: string | null = null @@ -163,9 +174,7 @@ function resolveExplicitPath(notes: NoteRef[], target: string): NoteRef | null { } function resolvePathSuffix(notes: NoteRef[], target: string): NoteRef | null { - const trimmed = stripMdExtension(normalizeSlashes(target.trim())) - .replace(/^\/+/, '') - .replace(/\/+$/, '') + const trimmed = trimSlashes(stripMdExtension(normalizeSlashes(target.trim()))) if (!trimmed) return null const suffix = normalizeForCompare(`/${trimmed}.md`) diff --git a/packages/app-core/src/lib/workspace-relocation.ts b/packages/app-core/src/lib/workspace-relocation.ts new file mode 100644 index 00000000..9a1d3c9e --- /dev/null +++ b/packages/app-core/src/lib/workspace-relocation.ts @@ -0,0 +1,8 @@ +/** Host filesystem work runs only after pending writes drain and editing locks. + * Each callback must finish its own partial rollback before rejecting. */ +export interface LocalVaultRelocation { + move: () => Promise + rollback: () => Promise + /** Omit when relocating a vault that is not open. Tokens belong to the host. */ + reopen?: { source: string; destination: string } +} diff --git a/packages/app-core/src/lib/workspace-transition.ts b/packages/app-core/src/lib/workspace-transition.ts new file mode 100644 index 00000000..04f873d1 --- /dev/null +++ b/packages/app-core/src/lib/workspace-transition.ts @@ -0,0 +1,43 @@ +import { useToastStore } from './toast' +import { useStore } from '../store' +import { lockVaultEditing } from './note-lifecycle-lock' + +let pending = false +let generation = 0 +let writesBlocked = false + +export function isWorkspaceTransitionPending(): boolean { return pending } +export function workspaceGeneration(): number { return generation } +export function workspaceWritesBlocked(): boolean { return writesBlocked } + +/** Reserve before the first await, including connection prompts and save drains. */ +export async function runWorkspaceTransition(work: () => Promise, silentIfBusy = false, propagateError = false): Promise { + if (pending) { + if (propagateError) throw new Error('Wait for the current vault change to finish.') + if (!silentIfBusy) useToastStore.getState().addToast('Wait for the current vault change to finish.', 'info') + return + } + pending = true + generation += 1 + // These belong to navigation invalidated by this generation, even if a picker cancels. + useStore.setState({ workspaceTransitioning: true, loadingNote: false, pendingJumpLocation: null, databasesLoading: {} }) + let unlock: (() => void) | undefined + try { + // Let operations already dispatched finish in their original vault. Then + // lock input and drain once more before any host changes its active root. + await useStore.getState().flushDirtyNotes() + const vault = useStore.getState().vault + if (vault) unlock = lockVaultEditing(vault) + writesBlocked = true + await useStore.getState().flushDirtyNotes() + await work() + } catch (error) { + if (propagateError) throw error + useToastStore.getState().addToast(error instanceof Error ? error.message : String(error), 'error') + } finally { + writesBlocked = false + unlock?.() + pending = false + useStore.setState({ workspaceTransitioning: false }) + } +} diff --git a/packages/app-core/src/navigation.test.ts b/packages/app-core/src/navigation.test.ts new file mode 100644 index 00000000..bcd6e64f --- /dev/null +++ b/packages/app-core/src/navigation.test.ts @@ -0,0 +1,209 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { findLeaf } from './lib/pane-layout' + +const original = new Map([ + ['one.md', '# First note\n\nOriginal body.\n'], + ['two.md', '# Second note\n\nDifferent body.\n'] +]) +let vault: Map +const disposers: Array<() => void> = [] + +function meta(path: string, body: string) { + return { + path, + title: path, + folder: 'inbox' as const, + siblingOrder: 0, + createdAt: 0, + updatedAt: 1, + size: body.length, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: body.slice(0, 40) + } +} + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + vault = new Map(original) + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + getCapabilities: () => ({ supportsRemoteWorkspace: false }), + listNotes: async () => [...vault].map(([path, body]) => meta(path, body)), + listFolders: async () => [], + listAssets: async () => [], + listLocalVaults: async () => [], + hasAssetsDir: async () => false, + getRemoteWorkspaceInfo: async () => null, + scanTasks: async () => [], + scanTasksForPath: async () => [], + readNote: async (path: string) => { + const body = vault.get(path) + if (body === undefined) throw new Error(`Missing note: ${path}`) + return { ...meta(path, body), body } + }, + writeNote: async (path: string, body: string) => { + vault.set(path, body) + return meta(path, body) + } + } + }) +}) + +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose() +}) + +async function setup() { + const { useStore } = await import('./store') + const navigation = await import('./navigation') + useStore.setState({ + notes: [...vault].map(([path, body]) => meta(path, body)) + }) + return { useStore, ...navigation } +} + +describe('public shell navigation', () => { + it('shows Home without closing tabs or modifying note bytes', async () => { + const { useStore, openNote, goHome } = await setup() + await openNote('one.md') + await openNote('two.md') + goHome() + const state = useStore.getState() + expect(findLeaf(state.paneLayout, state.activePaneId)).toMatchObject({ + tabs: ['one.md', 'two.md'], + activeTab: null + }) + expect(state.selectedPath).toBeNull() + expect(state.activeNote).toBeNull() + expect(vault).toEqual(original) + }) + + it('saves only the edited note when returning Home', async () => { + const { useStore, openNote, goHome } = await setup() + await openNote('one.md') + useStore + .getState() + .updateNoteBody('one.md', '# First note\n\nEdited body.\n') + goHome() + await vi.waitFor(() => + expect(useStore.getState().noteDirty['one.md']).toBe(false) + ) + expect(vault.get('one.md')).toBe('# First note\n\nEdited body.\n') + expect(vault.get('two.md')).toBe(original.get('two.md')) + expect(useStore.getState().selectedPath).toBeNull() + }) + + it('keeps Home during a rescan and still allows deliberate navigation', async () => { + const { useStore, openNote, goHome, installHomeGuard } = await setup() + disposers.push(installHomeGuard()) + await openNote('one.md') + await openNote('two.md') + goHome() + await useStore.getState().refreshNotes() + expect(useStore.getState().selectedPath).toBeNull() + await openNote('two.md') + expect(useStore.getState().selectedPath).toBe('two.md') + expect(useStore.getState().activeNote?.body).toBe(original.get('two.md')) + }) + + it('removes the Home guard when its shell unmounts', async () => { + const { useStore, openNote, goHome, installHomeGuard } = await setup() + const dispose = installHomeGuard() + disposers.push(dispose) + await openNote('one.md') + goHome() + dispose() + await useStore.getState().refreshNotes() + expect(useStore.getState().selectedPath).toBe('one.md') + }) + + it('uses the existing note history for back and forward', async () => { + const { useStore, openNote, goBack, goForward } = await setup() + const { getShellSnapshot } = await import('./shell') + expect(getShellSnapshot()).toMatchObject({ + canGoBack: false, + canGoForward: false + }) + await openNote('one.md') + await openNote('two.md') + expect(getShellSnapshot()).toMatchObject({ + canGoBack: true, + canGoForward: false, + selectedNote: { path: 'two.md' } + }) + await goBack() + expect(useStore.getState().selectedPath).toBe('one.md') + expect(getShellSnapshot()).toMatchObject({ + canGoForward: true, + selectedNote: { path: 'one.md' } + }) + await goForward() + expect(useStore.getState().selectedPath).toBe('two.md') + expect(getShellSnapshot().canGoForward).toBe(false) + expect(vault).toEqual(original) + }) +}) + +describe('navigation across workspace changes', () => { + function gate() { + let resolve!: (value: T) => void + return { promise: new Promise(done => { resolve = done }), resolve: (value: T) => resolve(value) } + } + it('does not read the old relative target in a new vault after a pending save', async () => { + const s = await setup() + s.useStore.setState({ vault: { root: '/one', name: 'One' } }) + await s.openNote('one.md') + const save = gate() + s.useStore.setState({ noteDirty: { 'one.md': true }, persistNote: () => save.promise }) + const read = vi.spyOn(window.zen, 'readNote') + const pending = s.openNote('two.md') + s.useStore.setState({ vault: { root: '/two', name: 'Two' } }) + save.resolve() + await pending + expect(read).not.toHaveBeenCalled() + expect(s.useStore.getState().selectedPath).toBe('one.md') + }) + it.each(['openNoteInPane', 'focusTabInPane'] as const)('does not install an old read through %s', async method => { + const s = await setup(), read = gate & { body: string }>() + s.useStore.setState({ vault: { root: '/one', name: 'One' } }) + vi.spyOn(window.zen, 'readNote').mockReturnValue(read.promise) + const pending = s.useStore.getState()[method](s.useStore.getState().activePaneId, 'one.md') + s.useStore.setState({ vault: { root: '/two', name: 'Two' } }) + read.resolve({ ...meta('one.md', 'old'), body: 'old' }) + await pending + expect(s.useStore.getState().noteContents).toEqual({}) + expect(s.useStore.getState().selectedPath).toBeNull() + }) + it('rejects a task read that finishes after the vault changes', async () => { + const s = await setup(), read = gate & { body: string }>() + const tasks = await import('./tasks') + const task = { id: 'task', sourcePath: 'one.md', noteFolder: 'inbox', lineNumber: 0, taskIndex: 0 } as import('@bridge-contract/tasks').VaultTask + s.useStore.setState({ vault: { root: '/one', name: 'One' }, vaultTasks: [task] }) + vi.spyOn(window.zen, 'readNote').mockReturnValue(read.promise) + const pending = tasks.openTask('task') + s.useStore.setState({ vault: { root: '/two', name: 'Two' } }) + read.resolve({ ...meta('one.md', '- [ ] Old task'), body: '- [ ] Old task' }) + expect(await pending).toBe(false) + expect(s.useStore.getState().pendingJumpLocation).toBeNull() + expect(s.useStore.getState().selectedPath).toBeNull() + }) + it.each(['one#Heading', 'one#^block'])('rejects a stale anchored wikilink %s', async target => { + const s = await setup(), read = gate & { body: string }>() + s.useStore.setState({ vault: { root: '/one', name: 'One' }, notes: [{ ...meta('one.md', ''), title: 'one' }] }) + const spy = vi.spyOn(window.zen, 'readNote').mockReturnValue(read.promise) + const pending = s.openWikilink(target) + await vi.waitFor(() => expect(spy).toHaveBeenCalled()) + s.useStore.setState({ vault: { root: '/two', name: 'Two' } }) + read.resolve({ ...meta('one.md', ''), body: '# Heading\n\nBlock ^block\n' }) + expect(await pending).toBe(false) + expect(s.useStore.getState().selectedPath).toBeNull() + expect(s.useStore.getState().pendingJumpLocation).toBeNull() + }) +}) diff --git a/packages/app-core/src/navigation.ts b/packages/app-core/src/navigation.ts new file mode 100644 index 00000000..d664bdc9 --- /dev/null +++ b/packages/app-core/src/navigation.ts @@ -0,0 +1,128 @@ +import { captureNavigationContext } from './lib/navigation-context' +import { useStore } from './store' +import { findLeaf, updateLeaf } from './lib/pane-layout' +import { paneModesWithPathMode, type PaneMode } from './lib/pane-mode' + +/** Open a note or app-generated page path through the normal save and history flow. */ +export function openNote(path: string, options?: { mode?: PaneMode }): Promise { + if (!captureNavigationContext()()) return Promise.resolve() + if (options?.mode) { + const mode = options.mode + useStore.setState(state => ({ + paneModes: { ...state.paneModes, [state.activePaneId]: paneModesWithPathMode(state.paneModes[state.activePaneId] ?? {}, path, mode) }, + ...(state.keepViewModeAcrossNotes ? { paneStickyModes: { ...state.paneStickyModes, [state.activePaneId]: mode } } : {}) + })) + } + return useStore.getState().selectNote(path) +} + +export function goBack(): Promise { + return useStore.getState().jumpToPreviousNote() +} + +export function goForward(): Promise { + return useStore.getState().jumpToNextNote() +} + +/** Observe the current selection without exposing mutable application state. */ +export function useSelectedNotePath(): string | null { + return useStore((state) => state.selectedPath) +} + +/** Show Home, retaining open tabs and starting the normal save for pending edits. */ +export function goHome(): void { + if (!captureNavigationContext()()) return + const state = useStore.getState() + if (state.selectedPath && state.noteDirty[state.selectedPath]) { + void state.persistNote(state.selectedPath) + } + const leaf = findLeaf(state.paneLayout, state.activePaneId) + if (!leaf || leaf.activeTab === null) return + const next = updateLeaf(state.paneLayout, leaf.id, (pane) => ({ + ...pane, + activeTab: null + })) + if (!next) return + useStore.setState({ + paneLayout: next, + selectedPath: null, + activeNote: null, + activeDirty: false + }) +} + +/** + * Install once for the lifetime of a shell that offers Home alongside open tabs. + * Register before mounting React or adding other store subscribers. Call the + * returned disposer when the host shell is torn down. + * + * A rescan or vault mutation can promote the first tab while rewriting paths. + * Restore Home only for that transition. Deliberate note navigation does not + * replace the note index and must remain visible. + */ +export function installHomeGuard(): () => void { + return useStore.subscribe((state, previous) => { + if ( + state.notes === previous.notes || + state.paneLayout === previous.paneLayout + ) + return + const leaf = findLeaf(state.paneLayout, state.activePaneId) + const before = findLeaf(previous.paneLayout, state.activePaneId) + if ( + !leaf || + !before || + before.activeTab !== null || + before.tabs.length === 0 + ) + return + if (leaf.activeTab === null || leaf.activeTab !== leaf.tabs[0]) return + const next = updateLeaf(state.paneLayout, leaf.id, (pane) => ({ + ...pane, + activeTab: null + })) + if (!next) return + useStore.setState({ + paneLayout: next, + selectedPath: null, + activeNote: null, + activeDirty: false + }) + }) +} + +/** Follow note, heading, block, or database links without taking editor focus. */ +export async function openWikilink(target: string): Promise { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false + const { resolveWikilinkPath } = await import('./lib/wikilinks') + const { openWikilinkTarget } = await import('./lib/wikilink-navigation') + const { listDatabaseLinkTargets, resolveDatabaseWikilink } = await import('./lib/database-links') + if (!isCurrent()) return false + const state = useStore.getState() + const path = resolveWikilinkPath(state.notes, target, state.selectedPath) + if (path) return openWikilinkTarget(path, target) + const database = resolveDatabaseWikilink(listDatabaseLinkTargets(state.folders, state.vaultSettings), target) + if (!database) return false + await state.openDatabase(database.csvPath) + return isCurrent() && !!useStore.getState().databases[database.csvPath] + +} + +export function openTodayDailyNote(): Promise { + return captureNavigationContext()() ? useStore.getState().openTodayDailyNote() : Promise.resolve() +} + +export type AppPage = 'tasks' | 'quick-notes' | 'tags' | 'assets' | 'archive' | 'trash' +export function openAppPage(page: AppPage): Promise { + if (!captureNavigationContext()()) return Promise.resolve() + const state = useStore.getState() + switch (page) { + case 'tasks': return state.openTasksView() + case 'quick-notes': return state.openQuickNotesView() + case 'tags': return state.openTagView('') + case 'assets': return state.openAssetsView() + case 'archive': return state.openArchiveView() + case 'trash': return state.openTrashView() + } +} diff --git a/packages/app-core/src/note-actions.test.ts b/packages/app-core/src/note-actions.test.ts new file mode 100644 index 00000000..2d841e00 --- /dev/null +++ b/packages/app-core/src/note-actions.test.ts @@ -0,0 +1,1380 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeLeaf } from "./lib/pane-layout"; + +beforeEach(() => { + vi.resetModules(); + localStorage.clear(); +}); +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +async function setup() { + const files = new Map([ + ["inbox/One.md", "# One\n\nOriginal.\n"], + ["inbox/Other.md", "See [[One]].\n"], + ]); + const metadata = (path: string) => ({ + path, + title: path.split("/").pop()!.replace(/\.md$/, ""), + folder: path.startsWith("archive/") + ? ("archive" as const) + : path.startsWith("trash/") + ? ("trash" as const) + : ("inbox" as const), + siblingOrder: 0, + createdAt: 1, + updatedAt: 1, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: "", + }); + const relocate = async (path: string, next: string) => { + if (!files.has(path)) throw new Error("Missing source"); + files.set(next, files.get(path)!); + files.delete(path); + return metadata(next); + }; + const bridge = { + getCapabilities: () => ({}), + listNotes: async () => [...files.keys()].map(metadata), + listFolders: async () => [ + { folder: "inbox" as const, subpath: "Work", siblingOrder: 0 }, + ], + hasAssetsDir: async () => false, + scanTasks: async () => [], + scanTasksForPath: async () => [], + getRemoteWorkspaceInfo: async () => null, + readNote: async (path: string) => ({ + ...metadata(path), + body: files.get(path)!, + }), + writeNote: vi.fn(async (path: string, body: string) => { + files.set(path, body); + return metadata(path); + }), + setVaultSettings: vi.fn(async (value) => value), + moveNote: vi.fn(async (path: string, folder: string, subpath: string) => + relocate(path, `${folder}/${subpath ? subpath + "/" : ""}One.md`), + ), + renameNote: vi.fn(async (path: string, title: string) => + relocate(path, `inbox/${title}.md`), + ), + archiveNote: vi.fn(async (path: string) => + relocate(path, "archive/One.md"), + ), + unarchiveNote: vi.fn(async (path: string) => + relocate(path, "inbox/One.md"), + ), + moveToTrash: vi.fn(async (path: string) => relocate(path, "trash/One.md")), + restoreFromTrash: vi.fn(async (path: string) => + relocate(path, "inbox/One.md"), + ), + deleteNote: vi.fn(async (path: string) => { + files.delete(path); + }), + }; + Object.defineProperty(window, "zen", { configurable: true, value: bridge }); + const { useStore } = await import("./store"); + const leaf = makeLeaf(["inbox/One.md", "inbox/Other.md"], "inbox/One.md"); + useStore.setState({ + vault: { root: "/test", name: "Test" }, + notes: [...files.keys()].map(metadata), + folders: await bridge.listFolders(), + paneLayout: leaf, + activePaneId: leaf.id, + selectedPath: "inbox/One.md", + noteContents: Object.fromEntries( + [...files].map(([path, body]) => [path, { ...metadata(path), body }]), + ), + noteDirty: {}, + syncTitleHeadingOnRename: false, + }); + return { useStore, files, bridge, relocate, metadata }; +} + +describe("note relocation saves", () => { + it("saves before moving and keeps edits made during the move at the canonical path", async () => { + const s = await setup(), + gate = deferred(); + s.useStore.getState().updateNoteBody("inbox/One.md", "Before move.\n"); + s.bridge.moveNote.mockImplementation(async (path) => { + expect(s.files.get(path)).toBe("Before move.\n"); + await gate.promise; + return s.relocate(path, "inbox/Work/One 2.md"); + }); + const moving = s.useStore + .getState() + .moveNote("inbox/One.md", "inbox", "Work"); + await vi.waitFor(() => expect(s.bridge.moveNote).toHaveBeenCalled()); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "During move: café 日本語. \n"); + gate.resolve(); + await moving; + expect(s.files.has("inbox/One.md")).toBe(false); + expect(s.files.get("inbox/Work/One 2.md")).toBe( + "During move: café 日本語. \n", + ); + expect(s.useStore.getState().selectedPath).toBe("inbox/Work/One 2.md"); + expect(s.useStore.getState().noteDirty["inbox/Work/One 2.md"]).toBe(false); + }); + + it("does not relocate a note if its save failed", async () => { + const s = await setup(); + vi.spyOn(console, "error").mockImplementation(() => {}); + s.bridge.writeNote.mockRejectedValue(new Error("disk full")); + s.useStore.getState().updateNoteBody("inbox/One.md", "Keep unsaved.\n"); + await s.useStore.getState().moveNote("inbox/One.md", "inbox", "Work"); + expect(s.bridge.moveNote).not.toHaveBeenCalled(); + expect(s.useStore.getState().noteContents["inbox/One.md"].body).toBe( + "Keep unsaved.\n", + ); + }); + + it("waits for a pending move before completing a vault-switch save", async () => { + const s = await setup(), + gate = deferred(); + s.bridge.moveNote.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "inbox/Work/One.md"); + }); + const moving = s.useStore + .getState() + .moveNote("inbox/One.md", "inbox", "Work"); + await vi.waitFor(() => expect(s.bridge.moveNote).toHaveBeenCalled()); + let flushed = false; + const flushing = s.useStore + .getState() + .flushDirtyNotes() + .then(() => { + flushed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(flushed).toBe(false); + gate.resolve(); + await Promise.all([moving, flushing]); + expect(s.files.has("inbox/Work/One.md")).toBe(true); + }); +}); + +async function publicSetup() { + const s = await setup(); + const actions = await import("./notes"); + const confirms = await import("./lib/confirm-requests"); + const prompts = await import("./lib/prompt-requests"); + const answer = (value: string | null) => + prompts.settlePromptRequest(prompts.getPromptRequest()!, value); + return { + ...s, + ...actions, + ...prompts, + ...confirms, + confirm: (value: boolean) => + confirms.settleConfirmRequest(confirms.getConfirmRequest()!, value), + answer, + host: { isCurrent: () => true }, + }; +} +describe("public note move", () => { + it("prompts, saves, and moves through the public action", async () => { + const s = await publicSetup(); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Saved via public action.\n"); + const moving = s.requestMoveNote(s.host, "inbox/One.md"); + expect(s.getPromptRequest()?.options.initialValue).toBe("inbox"); + s.answer("inbox/Work"); + expect(await moving).toBe("completed"); + expect(s.files.get("inbox/Work/One.md")).toBe("Saved via public action.\n"); + }); + it("cancels unchanged targets and invalid destinations without writes", async () => { + const s = await publicSetup(); + for (const target of [ + null, + "inbox", + "inbox/../Other", + "inbox/.hidden", + "inbox/People.base", + "inbox/People.base/pages", + "trash", + "inbox/bad\0name", + ]) { + const moving = s.requestMoveNote(s.host, "inbox/One.md"); + s.answer(target); + expect(await moving).toBe("cancelled"); + } + expect(s.bridge.moveNote).not.toHaveBeenCalled(); + }); + it("does not dispatch a dialog result after the host switches vaults", async () => { + const s = await publicSetup(); + let current = true; + const moving = s.requestMoveNote( + { isCurrent: () => current }, + "inbox/One.md", + ); + current = false; + s.answer("inbox/Work"); + expect(await moving).toBe("stale"); + expect(s.bridge.moveNote).not.toHaveBeenCalled(); + }); + it("propagates host errors and permits the next attempt", async () => { + const s = await publicSetup(); + s.bridge.moveNote.mockRejectedValueOnce(new Error("permission denied")); + const moving = s.requestMoveNote(s.host, "inbox/One.md"); + s.answer("inbox/Work"); + await expect(moving).rejects.toThrow("permission denied"); + const retry = s.requestMoveNote(s.host, "inbox/One.md"); + s.answer("inbox/Work"); + expect(await retry).toBe("completed"); + }); + it("rejects missing notes, database records, and simultaneous prompts", async () => { + const s = await publicSetup(); + expect(await s.requestMoveNote(s.host, "missing.md")).toBe("unavailable"); + s.useStore.setState({ + notes: [ + ...s.useStore.getState().notes, + s.metadata("inbox/People.base/Record.md"), + ], + }); + expect(await s.requestMoveNote(s.host, "inbox/People.base/Record.md")).toBe( + "unavailable", + ); + const first = s.requestMoveNote(s.host, "inbox/One.md"); + expect(await s.requestMoveNote(s.host, "inbox/Other.md")).toBe( + "unavailable", + ); + s.answer(null); + expect(await first).toBe("cancelled"); + }); +}); + +describe("note move coordination", () => { + it("preserves exact scope, comments, references, task metadata, and manual order", async () => { + const s = await setup(); + const { parseTasksFromBody } = await import("@shared/tasks"); + const path = "inbox/One.md", + neighbor = "inbox/One.md.backup"; + s.files.set(neighbor, "Keep backup.\n"); + s.useStore.setState({ + manualNoteOrder: { inbox: [path, "inbox/Other.md"] }, + noteComments: { + [path]: [ + { + id: "comment", + notePath: path, + body: "Keep comment", + anchorStart: 0, + anchorEnd: 0, + anchorText: "", + resolvedAt: null, + createdAt: 1, + updatedAt: 1, + }, + ], + }, + noteRefs: { [path]: { path, pinned: true } as never }, + vaultTasks: parseTasksFromBody("- [ ] Task\n", { + path, + title: "One", + folder: "inbox", + }), + noteContents: { + ...s.useStore.getState().noteContents, + [neighbor]: { ...s.metadata(neighbor), body: "Keep backup.\n" }, + }, + noteDirty: { [neighbor]: false }, + }); + await s.useStore.getState().moveNote(path, "archive", "Work", () => true); + const current = s.useStore.getState(), + next = "archive/Work/One.md"; + expect(current.noteComments[next][0].notePath).toBe(next); + expect(current.noteRefs[next].path).toBe(next); + expect(current.vaultTasks[0]).toMatchObject({ + sourcePath: next, + noteFolder: "archive", + noteTitle: "One", + }); + expect(current.manualNoteOrder.inbox).not.toContain(path); + expect(current.manualNoteOrder["archive/Work"]).toContain(next); + expect(s.files.get(neighbor)).toBe("Keep backup.\n"); + }); + + it("reconciles a dispatched move but leaves the new vault UI untouched", async () => { + const s = await setup(), + gate = deferred(); + s.bridge.moveNote.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "inbox/Work/One.md"); + }); + const moving = s.useStore + .getState() + .moveNote("inbox/One.md", "inbox", "Work", () => true); + await vi.waitFor(() => expect(s.bridge.moveNote).toHaveBeenCalled()); + const nextVault = { root: "/other", name: "Other" }; + s.useStore.setState({ + vault: nextVault, + notes: [], + noteContents: {}, + noteDirty: {}, + selectedPath: null, + activeNote: null, + }); + gate.resolve(); + await moving; + expect(s.useStore.getState().vault).toBe(nextVault); + expect(s.useStore.getState().notes).toEqual([]); + expect(s.useStore.getState().selectedPath).toBeNull(); + }); + + it("rejects a move during a closed-note task write, then permits it when settled", async () => { + const s = await setup(), + gate = deferred(); + const { parseTasksFromBody } = await import("@shared/tasks"); + const path = "inbox/One.md"; + s.files.set(path, "- [ ] Task\n"); + const task = parseTasksFromBody(s.files.get(path)!, { + path, + title: "One", + folder: "inbox", + })[0]; + s.useStore.setState({ + noteContents: {}, + noteDirty: {}, + vaultTasks: [task], + }); + s.bridge.writeNote.mockImplementationOnce(async (path, body) => { + await gate.promise; + s.files.set(path, body); + return s.metadata(path); + }); + const writing = s.useStore.getState().toggleTaskFromList(task); + await vi.waitFor(() => expect(s.bridge.writeNote).toHaveBeenCalled()); + await expect( + s.useStore.getState().moveNote(path, "inbox", "Work", () => true), + ).rejects.toThrow("pending task changes"); + expect(s.bridge.moveNote).not.toHaveBeenCalled(); + gate.resolve(); + await writing; + await s.useStore.getState().moveNote(path, "inbox", "Work", () => true); + expect(s.files.get("inbox/Work/One.md")).toBe("- [x] Task\n"); + expect(s.files.has(path)).toBe(false); + }); + + it("blocks task writes and retains buffers when a failed move cannot be rolled back", async () => { + const s = await setup(), + gate = deferred(); + const { parseTasksFromBody } = await import("@shared/tasks"); + const path = "inbox/One.md"; + s.bridge.moveNote.mockImplementation(async () => { + await gate.promise; + throw new Error("FOLDER_STATE_UNCERTAIN: rollback failed"); + }); + const moving = s.useStore + .getState() + .moveNote(path, "inbox", "Work", () => true); + const failed = expect(moving).rejects.toThrow("FOLDER_STATE_UNCERTAIN"); + await vi.waitFor(() => expect(s.bridge.moveNote).toHaveBeenCalled()); + const task = parseTasksFromBody("- [ ] Task\n", { + path, + title: "One", + folder: "inbox", + })[0]; + await s.useStore.getState().toggleTaskFromList(task); + expect(s.bridge.writeNote).not.toHaveBeenCalled(); + s.useStore.getState().updateNoteBody(path, "Retain pending edit.\n"); + gate.resolve(); + await failed; + await s.useStore.getState().persistNote(path); + expect(s.bridge.writeNote).not.toHaveBeenCalled(); + expect(s.useStore.getState().noteContents[path].body).toBe( + "Retain pending edit.\n", + ); + await expect(s.useStore.getState().flushDirtyNotes()).rejects.toThrow( + "FOLDER_STATE_UNCERTAIN", + ); + }); +}); + +it.each([false, true])( + "uses logical prompt paths with remapped primary folders (root: %s)", + async (root) => { + const s = await publicSetup(); + const path = root ? "Work/One.md" : "My Notes/Work/One.md"; + s.useStore.setState({ + notes: [s.metadata(path)], + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation: root ? "root" : "inbox", + systemFolderPaths: { inbox: "My Notes", archive: "Old Notes" }, + }, + }); + const moving = s.requestMoveNote(s.host, path); + expect(s.getPromptRequest()?.options.initialValue).toBe("inbox/Work"); + s.answer("inbox/Work"); + expect(await moving).toBe("cancelled"); + }, +); + +it("finishes comment writes before moving their sidecar", async () => { + const s = await setup(), + gate = deferred(); + const writeComments = vi.fn(async () => { + await gate.promise; + return []; + }); + Object.assign(s.bridge, { writeNoteComments: writeComments }); + s.useStore.setState({ noteComments: { "inbox/One.md": [] } }); + const commenting = s.useStore.getState().addNoteComment({ + notePath: "inbox/One.md", + body: "Pending comment", + } as never); + await vi.waitFor(() => expect(writeComments).toHaveBeenCalled()); + const moving = s.useStore + .getState() + .moveNote("inbox/One.md", "inbox", "Work", () => true); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(s.bridge.moveNote).not.toHaveBeenCalled(); + gate.resolve(); + await Promise.all([commenting, moving]); + expect(s.bridge.moveNote).toHaveBeenCalledOnce(); +}); + +it("flushes an open-buffer task edit before its debounce when moving", async () => { + const s = await setup(); + const { parseTasksFromBody } = await import("@shared/tasks"); + const path = "inbox/One.md", + body = "- [ ] Task\n"; + s.files.set(path, body); + const task = parseTasksFromBody(body, { + path, + title: "One", + folder: "inbox", + })[0]; + s.useStore.setState({ + noteContents: { [path]: { ...s.metadata(path), body } }, + noteDirty: {}, + vaultTasks: [task], + }); + await s.useStore.getState().toggleTaskFromList(task); + expect(s.files.get(path)).toBe(body); + await s.useStore.getState().moveNote(path, "inbox", "Work", () => true); + expect(s.files.get("inbox/Work/One.md")).toBe("- [x] Task\n"); + expect(s.files.has(path)).toBe(false); +}); + +describe("public note rename", () => { + it("rewrites clean and late-edited inbound buffers using the canonical title", async () => { + const s = await publicSetup(), + gate = deferred(); + const clean = "inbox/Clean.md"; + s.files.set(clean, "Clean [[One|alias]].\n"); + const pane = makeLeaf( + ["inbox/One.md", "inbox/Other.md", clean], + "inbox/One.md", + ); + s.useStore.setState({ + paneLayout: pane, + activePaneId: pane.id, + notes: [...s.useStore.getState().notes, s.metadata(clean)], + noteContents: { + ...s.useStore.getState().noteContents, + [clean]: { ...s.metadata(clean), body: s.files.get(clean)! }, + }, + }); + s.useStore.getState().updateNoteBody("inbox/Other.md", "Before [[One]].\n"); + s.bridge.renameNote.mockImplementation(async (path) => { + expect(s.files.get("inbox/Other.md")).toBe("Before [[One]].\n"); + await gate.promise; + return s.relocate(path, "inbox/Renamed 2.md"); + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + expect(s.getPromptRequest()?.options.initialValue).toBe("One"); + s.answer("Renamed"); + await vi.waitFor(() => expect(s.bridge.renameNote).toHaveBeenCalled()); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Late source: café 日本語. \n"); + s.useStore + .getState() + .updateNoteBody( + "inbox/Other.md", + "Late [[One#Heading|alias]] and `[[One]]`. \n", + ); + gate.resolve(); + expect(await renaming).toBe("completed"); + expect(s.files.has("inbox/One.md")).toBe(false); + expect(s.files.get("inbox/Renamed 2.md")).toBe( + "Late source: café 日本語. \n", + ); + expect(s.files.get("inbox/Other.md")).toBe( + "Late [[Renamed 2#Heading|alias]] and `[[One]]`. \n", + ); + expect(s.files.get(clean)).toBe("Clean [[Renamed 2|alias]].\n"); + expect(s.useStore.getState().noteContents[clean].body).toBe( + s.files.get(clean), + ); + expect(s.useStore.getState().selectedPath).toBe("inbox/Renamed 2.md"); + }); + + it("cancels empty, unchanged, and invalid titles without writing", async () => { + const s = await publicSetup(); + for (const title of [ + null, + "", + "One", + " One ", + "../Other", + "dir/Other", + "bad\\name", + ".hidden", + "Bad: title", + "Bad? title", + "bad\0name", + ]) { + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer(title); + expect(await renaming).toBe("cancelled"); + } + expect(s.bridge.renameNote).not.toHaveBeenCalled(); + }); + + it("rejects stale prompts and prevents overlap with a move prompt", async () => { + const s = await publicSetup(); + let current = true; + const renaming = s.requestRenameNote( + { isCurrent: () => current }, + "inbox/One.md", + ); + expect(await s.requestMoveNote(s.host, "inbox/Other.md")).toBe( + "unavailable", + ); + current = false; + s.answer("Renamed"); + expect(await renaming).toBe("stale"); + expect(s.bridge.renameNote).not.toHaveBeenCalled(); + }); + + it("blocks rename on a failed inbound save and releases the action for retry", async () => { + const s = await publicSetup(); + vi.spyOn(console, "error").mockImplementation(() => {}); + s.useStore + .getState() + .updateNoteBody("inbox/Other.md", "Unsaved [[One]].\n"); + s.bridge.writeNote.mockRejectedValue(new Error("disk full")); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await expect(renaming).rejects.toThrow("unsaved changes"); + expect(s.bridge.renameNote).not.toHaveBeenCalled(); + s.bridge.writeNote.mockImplementation(async (path, body) => { + s.files.set(path, body); + return s.metadata(path); + }); + const retry = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + expect(await retry).toBe("completed"); + expect(s.files.get("inbox/Other.md")).toBe("Unsaved [[Renamed]].\n"); + }); + + it("keeps rewritten buffers dirty if persistence fails after the rename", async () => { + const s = await publicSetup(); + vi.spyOn(console, "error").mockImplementation(() => {}); + s.bridge.writeNote.mockRejectedValue(new Error("disk full")); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await expect(renaming).rejects.toThrow("unsaved changes"); + expect(s.useStore.getState().selectedPath).toBe("inbox/Renamed.md"); + expect(s.useStore.getState().noteDirty["inbox/Other.md"]).toBe(true); + expect(s.useStore.getState().noteContents["inbox/Other.md"].body).toBe( + "See [[Renamed]].\n", + ); + }); + + it("finishes a dispatched rename before draining saves for a vault switch", async () => { + const s = await publicSetup(), + gate = deferred(); + let current = true; + s.bridge.renameNote.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "inbox/Renamed.md"); + }); + const renaming = s.requestRenameNote( + { isCurrent: () => current }, + "inbox/One.md", + ); + s.answer("Renamed"); + await vi.waitFor(() => expect(s.bridge.renameNote).toHaveBeenCalled()); + s.useStore.getState().updateNoteBody("inbox/Other.md", "During [[One]].\n"); + current = false; + let flushed = false; + const flushing = s.useStore + .getState() + .flushDirtyNotes() + .then(() => { + flushed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(flushed).toBe(false); + gate.resolve(); + expect(await renaming).toBe("stale"); + await flushing; + expect(s.files.get("inbox/Other.md")).toBe("During [[Renamed]].\n"); + expect(s.files.has("inbox/One.md")).toBe(false); + }); + + it("does not reconcile a dispatched rename into a replaced vault", async () => { + const s = await publicSetup(), + gate = deferred(); + s.bridge.renameNote.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "inbox/Renamed.md"); + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await vi.waitFor(() => expect(s.bridge.renameNote).toHaveBeenCalled()); + s.useStore.setState({ + vault: { root: "/other", name: "Other" }, + notes: [], + noteContents: {}, + noteDirty: {}, + selectedPath: null, + activeNote: null, + }); + gate.resolve(); + expect(await renaming).toBe("stale"); + expect(s.useStore.getState().notes).toEqual([]); + expect(s.useStore.getState().selectedPath).toBeNull(); + expect(s.bridge.writeNote).not.toHaveBeenCalled(); + expect(s.bridge.setVaultSettings).not.toHaveBeenCalled(); + }); +}); + +it("holds all task and note writes during rename and retains buffers on failed rollback", async () => { + const s = await publicSetup(), + gate = deferred(); + const { parseTasksFromBody } = await import("@shared/tasks"); + s.bridge.renameNote.mockImplementation(async () => { + await gate.promise; + throw new Error("FOLDER_STATE_UNCERTAIN: rollback failed"); + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + const failed = expect(renaming).rejects.toThrow("FOLDER_STATE_UNCERTAIN"); + await vi.waitFor(() => expect(s.bridge.renameNote).toHaveBeenCalled()); + const task = parseTasksFromBody("- [ ] Task\n", { + path: "inbox/Other.md", + title: "Other", + folder: "inbox", + })[0]; + await s.useStore.getState().toggleTaskFromList(task); + s.useStore.getState().updateNoteBody("inbox/Other.md", "Keep [[One]].\n"); + await s.useStore.getState().persistNote("inbox/Other.md"); + expect(s.bridge.writeNote).not.toHaveBeenCalled(); + gate.resolve(); + await failed; + expect(s.useStore.getState().noteContents["inbox/Other.md"].body).toBe( + "Keep [[One]].\n", + ); + await expect(s.useStore.getState().flushDirtyNotes()).rejects.toThrow( + "FOLDER_STATE_UNCERTAIN", + ); + expect(s.bridge.writeNote).not.toHaveBeenCalled(); +}); + +it("rewrites an edit that arrives during the post-rename refresh", async () => { + const s = await publicSetup(), + gate = deferred(); + const refresh = s.useStore.getState().refreshNotes; + const refreshing = vi.fn(async () => { + await gate.promise; + await refresh(); + }); + s.useStore.setState({ refreshNotes: refreshing }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await vi.waitFor(() => expect(refreshing).toHaveBeenCalled()); + s.useStore.getState().updateNoteBody("inbox/Other.md", "Late [[One]].\n"); + gate.resolve(); + expect(await renaming).toBe("completed"); + expect(s.files.get("inbox/Other.md")).toBe("Late [[Renamed]].\n"); +}); + +it("rejects rename during a closed-note tag rewrite and blocks a new tag rewrite during rename", async () => { + const s = await publicSetup(), + readGate = deferred(), + renameGate = deferred(); + s.files.set("inbox/Other.md", "#old [[One]]\n"); + s.useStore.setState({ + notes: s.useStore + .getState() + .notes.map((note) => + note.path === "inbox/Other.md" ? { ...note, tags: ["old"] } : note, + ), + noteContents: {}, + noteDirty: {}, + activeNote: null, + }); + const readNote = vi + .spyOn(s.bridge, "readNote") + .mockImplementationOnce(async (path) => { + await readGate.promise; + return { ...s.metadata(path), body: s.files.get(path)! }; + }); + const tagging = s.useStore.getState().renameTag("old", "new"); + await vi.waitFor(() => expect(readNote).toHaveBeenCalled()); + const rejected = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await expect(rejected).rejects.toThrow("pending note changes"); + expect(s.bridge.renameNote).not.toHaveBeenCalled(); + readGate.resolve(); + await tagging; + s.bridge.renameNote.mockImplementation(async (path) => { + await renameGate.promise; + return s.relocate(path, "inbox/Renamed.md"); + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await vi.waitFor(() => expect(s.bridge.renameNote).toHaveBeenCalled()); + readNote.mockClear(); + await s.useStore.getState().renameTag("new", "other"); + expect(readNote).not.toHaveBeenCalled(); + renameGate.resolve(); + expect(await renaming).toBe("completed"); +}); + +it("keeps the rename registered until final backlink saves finish", async () => { + const s = await publicSetup(), + gate = deferred(); + s.bridge.writeNote.mockImplementationOnce(async (path, body) => { + await gate.promise; + s.files.set(path, body); + return s.metadata(path); + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await vi.waitFor(() => expect(s.bridge.writeNote).toHaveBeenCalled()); + const read = vi.spyOn(s.bridge, "readNote"); + s.useStore.setState({ + notes: s.useStore + .getState() + .notes.map((note) => ({ ...note, tags: ["tag"] })), + }); + await s.useStore.getState().renameTag("tag", "changed"); + expect(read).not.toHaveBeenCalled(); + let flushed = false; + const flushing = s.useStore + .getState() + .flushDirtyNotes() + .then(() => { + flushed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(flushed).toBe(false); + gate.resolve(); + expect(await renaming).toBe("completed"); + await flushing; + expect(s.files.get("inbox/Other.md")).toBe("See [[Renamed]].\n"); +}); + +it("drains a closed-note writer before a vault-switch save completes", async () => { + const s = await setup(), + gate = deferred(); + s.files.set("inbox/Other.md", "#old [[One]]\n"); + s.useStore.setState({ + notes: s.useStore + .getState() + .notes.map((note) => ({ ...note, tags: ["old"] })), + noteContents: {}, + noteDirty: {}, + activeNote: null, + }); + const read = vi + .spyOn(s.bridge, "readNote") + .mockImplementationOnce(async (path) => { + await gate.promise; + return { ...s.metadata(path), body: s.files.get(path)! }; + }); + const tagging = s.useStore.getState().renameTag("old", "new"); + await vi.waitFor(() => expect(read).toHaveBeenCalled()); + let flushed = false; + const flushing = s.useStore + .getState() + .flushDirtyNotes() + .then(() => { + flushed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(flushed).toBe(false); + gate.resolve(); + await Promise.all([tagging, flushing]); + expect(s.files.get("inbox/Other.md")).toBe("#new [[One]]\n"); +}); + +it("refreshes visible task text after renamed backlink buffers are saved", async () => { + const s = await publicSetup(); + const { parseTasksFromBody, TASKS_TAB_PATH } = await import("@shared/tasks"); + s.files.set("inbox/Other.md", "- [ ] Read [[One]]\n"); + const pane = makeLeaf( + ["inbox/One.md", "inbox/Other.md", TASKS_TAB_PATH], + TASKS_TAB_PATH, + ); + const scanTasks = vi.fn(async () => + parseTasksFromBody( + s.files.get("inbox/Other.md")!, + s.metadata("inbox/Other.md"), + ), + ); + Object.assign(s.bridge, { scanTasks }); + s.useStore.setState({ + paneLayout: pane, + activePaneId: pane.id, + selectedPath: TASKS_TAB_PATH, + noteContents: { + "inbox/Other.md": { + ...s.metadata("inbox/Other.md"), + body: s.files.get("inbox/Other.md")!, + }, + }, + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + expect(await renaming).toBe("completed"); + expect(scanTasks).toHaveBeenCalled(); + expect(s.useStore.getState().vaultTasks[0].rawText).toContain("[[Renamed]]"); +}); + +it("updates open inbound links when a database record page is renamed", async () => { + const s = await setup(); + const csvPath = "inbox/Records.base/data.csv"; + Object.assign(s.bridge, { writeDatabaseSchema: vi.fn(async () => {}) }); + s.useStore.setState({ + databases: { + [csvPath]: { + version: 1, + idFieldId: "id", + path: csvPath, + title: "Records", + fields: [], + views: [], + activeViewId: "view", + rows: [{ id: "row", cells: { id: "row" } }], + pages: { row: "inbox/One.md" }, + }, + }, + }); + s.bridge.renameNote.mockImplementation(async (path) => + s.relocate(path, "inbox/Renamed.md"), + ); + await s.useStore.getState().renameRecordPage(csvPath, "row"); + expect(s.useStore.getState().databases[csvPath].pages?.row).toBe( + "inbox/Renamed.md", + ); + expect(s.files.get("inbox/Other.md")).toBe("See [[Renamed]].\n"); + await s.useStore.getState().flushDirtyNotes(); +}); + +it("refreshes already-mounted Home tasks after rename", async () => { + const s = await publicSetup(); + const { parseTasksFromBody } = await import("@shared/tasks"); + s.files.set("inbox/Other.md", "- [ ] Read [[One]]\n"); + const scanTasks = vi.fn(async () => + parseTasksFromBody( + s.files.get("inbox/Other.md")!, + s.metadata("inbox/Other.md"), + ), + ); + Object.assign(s.bridge, { scanTasks }); + const marker = document.createElement("div"); + marker.dataset.homeNav = "tasks"; + document.body.append(marker); + try { + s.useStore.setState({ + selectedPath: null, + activeNote: null, + vaultTasks: await scanTasks(), + noteContents: { + "inbox/Other.md": { + ...s.metadata("inbox/Other.md"), + body: s.files.get("inbox/Other.md")!, + }, + }, + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + expect(await renaming).toBe("completed"); + expect(s.useStore.getState().vaultTasks[0].rawText).toContain( + "[[Renamed]]", + ); + } finally { + marker.remove(); + } +}); + +describe("note lifecycle saves", () => { + it.each(["archive", "trash"] as const)( + "saves edits made during %s before closing the note", + async (action) => { + const s = await setup(), + gate = deferred(); + const method = + action === "archive" ? s.bridge.archiveNote : s.bridge.moveToTrash; + method.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, `${action}/One 2.md`); + }); + const changing = s.useStore + .getState() + .changeNoteLifecycle("inbox/One.md", action, () => true); + await vi.waitFor(() => expect(method).toHaveBeenCalled()); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Late edit: café 日本語. \n"); + gate.resolve(); + await changing; + expect(s.files.get(`${action}/One 2.md`)).toBe( + "Late edit: café 日本語. \n", + ); + expect(s.files.has("inbox/One.md")).toBe(false); + expect( + s.useStore.getState().noteContents[`${action}/One 2.md`], + ).toBeUndefined(); + expect(s.useStore.getState().selectedPath).not.toBe(`${action}/One 2.md`); + }, + ); + + it("keeps the destination buffer open and dirty when saving after archive fails", async () => { + const s = await setup(), + gate = deferred(); + vi.spyOn(console, "error").mockImplementation(() => {}); + s.bridge.archiveNote.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "archive/One.md"); + }); + const changing = s.useStore + .getState() + .changeNoteLifecycle("inbox/One.md", "archive", () => true); + const failed = expect(changing).rejects.toThrow("unsaved changes"); + await vi.waitFor(() => expect(s.bridge.archiveNote).toHaveBeenCalled()); + s.useStore.getState().updateNoteBody("inbox/One.md", "Keep this draft.\n"); + s.bridge.writeNote.mockRejectedValue(new Error("disk full")); + gate.resolve(); + await failed; + expect(s.useStore.getState().selectedPath).toBe("archive/One.md"); + expect(s.useStore.getState().noteContents["archive/One.md"].body).toBe( + "Keep this draft.\n", + ); + expect(s.useStore.getState().noteDirty["archive/One.md"]).toBe(true); + }); + + it.each(["archive", "trash"] as const)( + "restores a %s note to the canonical path and keeps its tab", + async (folder) => { + const s = await setup(); + const path = `${folder}/One.md`, + body = "Restore exact bytes. \n"; + s.files.set(path, body); + const leaf = makeLeaf([path], path); + s.useStore.setState({ + notes: [s.metadata(path)], + paneLayout: leaf, + activePaneId: leaf.id, + selectedPath: path, + noteContents: { [path]: { ...s.metadata(path), body } }, + noteDirty: {}, + }); + await s.useStore + .getState() + .changeNoteLifecycle(path, "restore", () => true); + expect(s.useStore.getState().selectedPath).toBe("inbox/One.md"); + expect(s.files.get("inbox/One.md")).toBe(body); + expect(s.files.has(path)).toBe(false); + }, + ); + + it("retains the source buffer when the host refuses a lifecycle move", async () => { + const s = await setup(); + s.bridge.archiveNote.mockRejectedValue(new Error("permission denied")); + await expect( + s.useStore + .getState() + .changeNoteLifecycle("inbox/One.md", "archive", () => true), + ).rejects.toThrow("permission denied"); + expect(s.useStore.getState().selectedPath).toBe("inbox/One.md"); + expect(s.files.has("inbox/One.md")).toBe(true); + }); +}); + +describe("irreversible note lifecycle", () => { + it.each(["delete", "system-trash"] as const)( + "freezes every editor for %s until it commits", + async (action) => { + const s = await setup(), + gate = deferred(); + const { EditorState } = await import("@codemirror/state"); + const { EditorView } = await import("@codemirror/view"); + const { noteEditingLockExtension } = + await import("./lib/note-lifecycle-lock"); + if (action === "system-trash") + s.useStore.setState({ + vault: { ...s.useStore.getState().vault!, temporary: true }, + }); + const views = [ + "inbox/One.md", + "inbox/One.md", + "inbox/One.md", + "inbox/Other.md", + ].map( + (path) => + new EditorView({ + state: EditorState.create({ + doc: s.files.get(path), + extensions: [ + noteEditingLockExtension(() => ({ + vault: s.useStore.getState().vault, + path, + })), + ], + }), + parent: document.body, + }), + ); + const method = + action === "delete" ? s.bridge.deleteNote : s.bridge.moveToTrash; + if (action === "delete") + s.bridge.deleteNote.mockImplementation(async (path) => { + await gate.promise; + s.files.delete(path); + }); + else + s.bridge.moveToTrash.mockImplementation(async (path) => { + await gate.promise; + s.files.delete(path); + return s.metadata(path); + }); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Save before deleting.\n"); + try { + const operation = s.useStore + .getState() + .changeNoteLifecycle( + "inbox/One.md", + action === "delete" ? "delete" : "trash", + () => true, + ); + for (const view of views.slice(0, 3)) { + expect(view.state.readOnly).toBe(true); + const before = view.state.doc.toString(); + view.dispatch({ changes: { from: 0, insert: "Rejected" } }); + expect(view.state.doc.toString()).toBe(before); + } + expect(views[3].state.readOnly).toBe(false); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Rejected late edit"); + await vi.waitFor(() => expect(method).toHaveBeenCalled()); + expect(s.files.get("inbox/One.md")).toBe("Save before deleting.\n"); + gate.resolve(); + await operation; + await s.useStore.getState().flushDirtyNotes(); + expect(s.files.has("inbox/One.md")).toBe(false); + expect( + s.useStore.getState().noteContents["inbox/One.md"], + ).toBeUndefined(); + for (const view of views) expect(view.state.readOnly).toBe(false); + } finally { + gate.resolve(); + views.forEach((view) => view.destroy()); + } + }, + ); + + it("unlocks after host failure and accepts the next edit", async () => { + const s = await setup(); + s.bridge.deleteNote.mockRejectedValue(new Error("denied")); + await expect( + s.useStore.getState().changeNoteLifecycle("inbox/One.md", "delete"), + ).rejects.toThrow("denied"); + s.useStore.getState().updateNoteBody("inbox/One.md", "Editable again.\n"); + await s.useStore.getState().flushDirtyNotes(); + expect(s.files.get("inbox/One.md")).toBe("Editable again.\n"); + expect(s.useStore.getState().selectedPath).toBe("inbox/One.md"); + }); + + it("does not delete after the initial save fails", async () => { + const s = await setup(); + vi.spyOn(console, "error").mockImplementation(() => {}); + s.useStore.getState().updateNoteBody("inbox/One.md", "Keep draft.\n"); + s.bridge.writeNote.mockRejectedValue(new Error("disk full")); + await expect( + s.useStore.getState().changeNoteLifecycle("inbox/One.md", "delete"), + ).rejects.toThrow(); + expect(s.bridge.deleteNote).not.toHaveBeenCalled(); + expect(s.useStore.getState().noteContents["inbox/One.md"].body).toBe( + "Keep draft.\n", + ); + expect(s.useStore.getState().noteDirty["inbox/One.md"]).toBe(true); + s.bridge.writeNote.mockImplementation(async (path, body) => { + s.files.set(path, body); + return s.metadata(path); + }); + await s.useStore.getState().flushDirtyNotes(); + }); +}); + +describe("public note lifecycle", () => { + it("saves edits made while confirmation is open and restores exact bytes", async () => { + const s = await publicSetup(); + const trashing = s.requestTrashNote(s.host, "inbox/One.md"); + expect(s.getConfirmRequest()?.options.confirmLabel).toBe("Move to Trash"); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Confirmed draft café. \n"); + s.confirm(true); + expect(await trashing).toBe("completed"); + expect(s.files.get("trash/One.md")).toBe("Confirmed draft café. \n"); + expect(await s.restoreNote(s.host, "trash/One.md")).toBe("completed"); + expect(s.files.get("inbox/One.md")).toBe("Confirmed draft café. \n"); + }); + + it("cancels and rejects stale confirmations without touching the host", async () => { + const s = await publicSetup(); + let current = true; + const host = { isCurrent: () => current }; + const cancelled = s.requestTrashNote(host, "inbox/One.md"); + s.confirm(false); + expect(await cancelled).toBe("cancelled"); + const stale = s.requestTrashNote(host, "inbox/One.md"); + current = false; + s.confirm(true); + expect(await stale).toBe("stale"); + expect(s.bridge.moveToTrash).not.toHaveBeenCalled(); + }); + + it("only offers permanent deletion for trashed ordinary notes", async () => { + const s = await publicSetup(); + expect(await s.requestDeleteNotePermanently(s.host, "inbox/One.md")).toBe( + "unavailable", + ); + const trashing = s.requestTrashNote(s.host, "inbox/One.md"); + s.confirm(true); + await trashing; + const deleting = s.requestDeleteNotePermanently(s.host, "trash/One.md"); + expect(s.getConfirmRequest()?.options.danger).toBe(true); + s.confirm(true); + expect(await deleting).toBe("completed"); + expect(s.files.has("trash/One.md")).toBe(false); + }); + + it("archives and unarchives through the public boundary", async () => { + const s = await publicSetup(); + expect(await s.requestArchiveNote(s.host, "inbox/One.md")).toBe( + "completed", + ); + expect(await s.requestArchiveNote(s.host, "archive/One.md")).toBe( + "unavailable", + ); + expect(await s.restoreNote(s.host, "archive/One.md")).toBe("completed"); + }); + + it("keeps other editor restrictions when the deletion lock is removed", async () => { + const { EditorState } = await import("@codemirror/state"); + const { EditorView } = await import("@codemirror/view"); + const { lockNoteEditing, noteEditingLockExtension } = + await import("./lib/note-lifecycle-lock"); + const vault = {}, + path = "note.md"; + const view = new EditorView({ + state: EditorState.create({ + doc: "Body", + extensions: [ + noteEditingLockExtension(() => ({ vault, path })), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + ], + }), + parent: document.body, + }); + try { + expect(view.state.facet(EditorView.editable)).toBe(false); + const unlock = lockNoteEditing(vault, path); + unlock(); + expect(view.state.readOnly).toBe(true); + expect(view.state.facet(EditorView.editable)).toBe(false); + } finally { + view.destroy(); + } + }); +}); + +it("waits for an IME composition to finish before deleting", async () => { + const s = await setup(); + const { EditorState } = await import("@codemirror/state"); + const { EditorView } = await import("@codemirror/view"); + const { noteEditingLockExtension } = + await import("./lib/note-lifecycle-lock"); + const view = new EditorView({ + state: EditorState.create({ + doc: "Body", + extensions: [ + noteEditingLockExtension(() => ({ + vault: s.useStore.getState().vault, + path: "inbox/One.md", + })), + ], + }), + parent: document.body, + }); + Object.defineProperty(view, "composing", { get: () => true }); + try { + await expect( + s.useStore.getState().changeNoteLifecycle("inbox/One.md", "delete"), + ).rejects.toThrow("Finish entering text"); + expect(s.bridge.deleteNote).not.toHaveBeenCalled(); + expect(view.state.readOnly).toBe(false); + } finally { + view.destroy(); + } +}); + +it("does not claim system Trash succeeded if its token changes while saving", async () => { + const s = await setup(), + gate = deferred(); + let current = true; + const { useToastStore } = await import("./lib/toast"); + s.useStore.setState({ + vault: { ...s.useStore.getState().vault!, temporary: true }, + }); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Saved before switching.\n"); + s.bridge.writeNote.mockImplementation(async (path, body) => { + await gate.promise; + s.files.set(path, body); + return s.metadata(path); + }); + const deleting = s.useStore + .getState() + .changeNoteLifecycle("inbox/One.md", "trash", () => current); + await vi.waitFor(() => expect(s.bridge.writeNote).toHaveBeenCalled()); + current = false; + gate.resolve(); + await deleting; + expect(s.bridge.moveToTrash).not.toHaveBeenCalled(); + expect(s.files.has("inbox/One.md")).toBe(true); + expect( + useToastStore + .getState() + .toasts.some((toast) => toast.message.includes("Moved to system Trash")), + ).toBe(false); +}); + +it("finishes saving a dispatched trash operation in its original vault after its host token changes", async () => { + const s = await publicSetup(), + gate = deferred(); + let current = true; + s.bridge.moveToTrash.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "trash/One 2.md"); + }); + const trashing = s.requestTrashNote( + { isCurrent: () => current }, + "inbox/One.md", + ); + s.confirm(true); + await vi.waitFor(() => expect(s.bridge.moveToTrash).toHaveBeenCalled()); + current = false; + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Late edit before vault switch.\n"); + gate.resolve(); + expect(await trashing).toBe("stale"); + expect(s.files.get("trash/One 2.md")).toBe( + "Late edit before vault switch.\n", + ); + expect(s.useStore.getState().noteContents["trash/One 2.md"]).toBeUndefined(); + expect(s.files.has("inbox/One.md")).toBe(false); +}); + +it('stops a bulk trash after failure and reports completed source paths', async () => { + const s = await publicSetup() + s.bridge.moveToTrash.mockImplementation(async path => { + if (path.endsWith('Other.md')) throw new Error('Second move refused') + return s.relocate(path, 'trash/One.md') + }) + s.useStore.getState().updateNoteBody('inbox/Other.md', 'Keep the second draft.\n') + const batch = s.requestNoteBatch(s.host, ['inbox/One.md','inbox/Other.md'], 'trash') + const failure = expect(batch).rejects.toMatchObject({name:'NoteBatchError',completed:['inbox/One.md'],unconfirmed:['inbox/Other.md']}) + s.confirm(true) + await failure + expect(s.files.has('trash/One.md')).toBe(true) + expect(s.files.get('inbox/Other.md')).toBe('Keep the second draft.\n') + expect(s.useStore.getState().noteContents['inbox/Other.md'].body).toBe('Keep the second draft.\n') +}) + +it('deduplicates a confirmed batch and cancels the whole selection together', async () => { + const s=await publicSetup() + const cancelled=s.requestNoteBatch(s.host,['inbox/One.md','inbox/Other.md'],'trash') + s.confirm(false) + expect((await cancelled).status).toBe('cancelled') + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + const batch=s.requestNoteBatch(s.host,['inbox/One.md','inbox/One.md'],'trash') + s.confirm(true) + expect(await batch).toEqual({status:'completed',completed:['inbox/One.md'],unconfirmed:[]}) + expect(s.bridge.moveToTrash).toHaveBeenCalledTimes(1) +}) + +it('stops a batch if its host token changes after dispatch',async()=>{ + const s=await publicSetup() + let current=true + s.bridge.archiveNote.mockImplementation(async path=>{current=false;return s.relocate(path,'archive/One.md')}) + const result=await s.requestNoteBatch({isCurrent:()=>current},['inbox/One.md','inbox/Other.md'],'archive') + expect(result.status).toBe('stale') + expect(s.bridge.archiveNote).toHaveBeenCalledTimes(1) + expect(s.files.has('inbox/Other.md')).toBe(true) + expect(s.files.has('archive/One.md')).toBe(true) +}) + +it('saves and freezes all notes in remapped Trash before emptying it',async()=>{ + const s=await publicSetup(),gate=deferred() + const emptyTrash=vi.fn(async()=>{expect(s.files.get('Bin/Nested/One.md')).toBe('Saved draft.\n');await gate.promise;for(const path of s.files.keys())if(path.startsWith('Bin/'))s.files.delete(path)}) + Object.assign(s.bridge,{emptyTrash}) + s.files.set('Bin/Nested/One.md','Original') + const note={...s.metadata('Bin/Nested/One.md'),folder:'trash' as const,body:'Original'} + s.useStore.setState({vaultSettings:{...s.useStore.getState().vaultSettings,systemFolderPaths:{trash:'Bin'}},notes:[...s.useStore.getState().notes,note],noteContents:{...s.useStore.getState().noteContents,[note.path]:note}}) + s.useStore.getState().updateNoteBody(note.path,'Saved draft.\n') + const emptying=s.requestEmptyTrash(s.host) + s.confirm(true) + await vi.waitFor(()=>expect(emptyTrash).toHaveBeenCalled()) + s.useStore.getState().updateNoteBody(note.path,'Rejected edit') + expect(s.useStore.getState().noteContents[note.path].body).toBe('Saved draft.\n') + s.useStore.getState().updateNoteBody('inbox/Other.md','Still editable.\n') + gate.resolve() + expect(await emptying).toBe('completed') + await s.useStore.getState().flushDirtyNotes() + expect(s.files.has(note.path)).toBe(false) + expect(s.useStore.getState().noteContents[note.path]).toBeUndefined() + expect(s.files.get('inbox/Other.md')).toBe('Still editable.\n') +}) + +it('keeps Trash editors available when emptying fails',async()=>{ + const s=await publicSetup() + Object.assign(s.bridge,{emptyTrash:vi.fn(async()=>{throw new Error('denied')})}) + s.files.set('trash/One.md','Keep me') + const note={...s.metadata('trash/One.md'),body:'Keep me'} + s.useStore.setState({notes:[note],noteContents:{[note.path]:note}}) + const emptying=s.requestEmptyTrash(s.host) + const rejected=expect(emptying).rejects.toThrow('denied') + s.confirm(true) + await rejected + s.useStore.getState().updateNoteBody(note.path,'Editable after failure') + await s.useStore.getState().flushDirtyNotes() + expect(s.files.get(note.path)).toBe('Editable after failure') +}) diff --git a/packages/app-core/src/notes.ts b/packages/app-core/src/notes.ts new file mode 100644 index 00000000..63b9c74f --- /dev/null +++ b/packages/app-core/src/notes.ts @@ -0,0 +1,337 @@ +import { isWorkspaceTransitionPending } from './lib/workspace-transition'; +import type { NoteMeta } from "@shared/ipc"; +import { formDirContaining } from "@shared/databases"; +import { useStore } from "./store"; +import { confirmApp, getConfirmRequest } from "./lib/confirm-requests"; +import { getPromptRequest, promptApp } from "./lib/prompt-requests"; +import { + buildMoveNotePrompt, + parseMoveNoteTarget, + validateMoveNoteTarget, +} from "./lib/move-note"; +import { noteFolderSubpath } from "./lib/vault-layout"; +import { + confirmDeletePermanently, + confirmMoveToTrash, +} from "./lib/confirm-trash"; + +export interface NoteActionHost { + /** Capture the native vault token before opening the prompt and compare it here. */ + isCurrent(): boolean; +} + +/** Operational errors reject. Dispatched work may complete in its original vault. */ +export type NoteActionResult = + | "completed" + | "cancelled" + | "stale" + | "unavailable"; + +let pending = false; + +function validateDestination(value: string): string | null { + const error = validateMoveNoteTarget(value); + if (error) return error; + const { subpath } = parseMoveNoteTarget(value); + if ( + /[\u0000-\u001f]/.test(value) || + subpath.split("/").some((part) => part.startsWith(".")) + ) + return "Choose a folder without hidden names or parent-directory segments."; + if (formDirContaining(subpath)) + return "Database record folders are not move destinations."; + return null; +} + +function captureNoteActionContext(host: NoteActionHost): () => boolean { + const state = useStore.getState(); + const vault = state.vault; + const bridge = window.zen; + const layout = (settings: typeof state.vaultSettings) => + JSON.stringify([settings.primaryNotesLocation, settings.systemFolderPaths]); + const originalLayout = layout(state.vaultSettings); + const isCurrent = () => { + try { + const current = useStore.getState(); + return ( + !isWorkspaceTransitionPending() && + current.vault === vault && + window.zen === bridge && + layout(current.vaultSettings) === originalLayout && + host.isCurrent() + ); + } catch { + return false; + } + }; + return isCurrent; +} + +async function requestNoteAction( + host: NoteActionHost, + path: string, + action: ( + state: ReturnType, + note: NoteMeta, + isCurrent: () => boolean, + ) => Promise, + allowed: (note: NoteMeta) => boolean = (note) => note.folder !== "trash", +): Promise { + if ( + pending || + getPromptRequest() || + getConfirmRequest() || + formDirContaining(path) + ) + return "unavailable"; + const state = useStore.getState(); + const note = state.notes.find((note) => note.path === path); + if (!state.vault || !note || !allowed(note)) return "unavailable"; + const isCurrent = captureNoteActionContext(host); + if (!isCurrent()) return "unavailable"; + pending = true; + try { + return await action(state, note, isCurrent); + } finally { + pending = false; + } +} + +/** Prompt to move an ordinary note using logical inbox/archive folder names. */ +export async function requestMoveNote( + host: NoteActionHost, + path: string, +): Promise { + return requestNoteAction(host, path, async (state, note, isCurrent) => { + const subpath = noteFolderSubpath(note, state.vaultSettings); + const initialValue = + note.folder === "archive" || note.folder === "inbox" + ? [note.folder, subpath].filter(Boolean).join("/") + : "inbox"; + const target = await promptApp({ + ...buildMoveNotePrompt( + note, + state.folders.filter((folder) => !formDirContaining(folder.subpath)), + ), + initialValue, + validate: validateDestination, + }); + if (!target || validateDestination(target)) return "cancelled"; + if ( + !isCurrent() || + !useStore.getState().notes.some((note) => note.path === path) + ) + return "stale"; + const destination = parseMoveNoteTarget(target); + if (destination.folder === note.folder && destination.subpath === subpath) + return "cancelled"; + await useStore + .getState() + .moveNote(path, destination.folder, destination.subpath, isCurrent); + return isCurrent() ? "completed" : "stale"; + }); +} + +function validateTitle(value: string): string | null { + if (!value.trim()) return "Enter a note title."; + if (/[/\\:*?"<>|\u0000-\u001f]/.test(value) || value.trim().startsWith(".")) + return "Choose a title without reserved filename characters, control characters, or a leading dot."; + return null; +} + +/** Rename a note and update inbound wikilinks, including cached editor buffers. */ +export async function requestRenameNote( + host: NoteActionHost, + path: string, +): Promise { + return requestNoteAction(host, path, async (_state, note, isCurrent) => { + const title = await promptApp({ + title: "Rename note", + initialValue: note.title, + okLabel: "Rename", + validate: validateTitle, + }); + if (title === null || validateTitle(title) || title.trim() === note.title) + return "cancelled"; + if ( + !isCurrent() || + !useStore.getState().notes.some((note) => note.path === path) + ) + return "stale"; + await useStore.getState().renameNote(path, title.trim(), isCurrent); + return isCurrent() ? "completed" : "stale"; + }); +} + +async function requestLifecycle( + host: NoteActionHost, + path: string, + action: "archive" | "trash" | "restore" | "delete", +): Promise { + const allowed = (note: NoteMeta) => { + if (action === "restore") + return note.folder === "archive" || note.folder === "trash"; + if (action === "delete") return note.folder === "trash"; + if (action === "archive") + return note.folder === "inbox" || note.folder === "quick"; + return note.folder !== "trash"; + }; + return requestNoteAction( + host, + path, + async (state, note, isCurrent) => { + const confirmed = + action === "archive" + ? await state.confirmArchiveNotes([path]) + : action === "trash" + ? await confirmMoveToTrash( + note.title, + state.vault?.temporary === true, + ) + : action === "delete" + ? await confirmDeletePermanently(note.title) + : true; + if (!confirmed) return "cancelled"; + const current = useStore + .getState() + .notes.find((note) => note.path === path); + if (!isCurrent() || !current || !allowed(current)) return "stale"; + await useStore.getState().changeNoteLifecycle(path, action, isCurrent); + return isCurrent() ? "completed" : "stale"; + }, + allowed, + ); +} + +/** Save and archive a note, confirming when it contains unfinished tasks. */ +export function requestArchiveNote( + host: NoteActionHost, + path: string, +): Promise { + return requestLifecycle(host, path, "archive"); +} + +/** Confirm and save before moving to vault Trash (system Trash in temporary sessions). */ +export function requestTrashNote( + host: NoteActionHost, + path: string, +): Promise { + return requestLifecycle(host, path, "trash"); +} + +/** Restore an archived or trashed note to the configured primary notes location. */ +export function restoreNote( + host: NoteActionHost, + path: string, +): Promise { + return requestLifecycle(host, path, "restore"); +} + +/** Confirm, save, and permanently delete a trashed note. */ +export function requestDeleteNotePermanently( + host: NoteActionHost, + path: string, +): Promise { + return requestLifecycle(host, path, "delete"); +} + +export type NoteBatchAction = 'archive' | 'trash' | 'restore' | 'delete' | 'move' +export interface NoteBatchResult { + readonly status: NoteActionResult + /** Source paths confirmed complete before any stale transition. */ + readonly completed: readonly string[] + /** A stale/failed current item may already have moved. Read the snapshot before retrying. */ + readonly unconfirmed: readonly string[] +} + +export class NoteBatchError extends Error { + readonly completed: readonly string[] + readonly unconfirmed: readonly string[] + constructor(completed: string[], unconfirmed: string[], readonly originalError: unknown) { + super(`${completed.length} note actions completed. ${originalError instanceof Error ? originalError.message : String(originalError)}`) + this.name = 'NoteBatchError' + this.completed = Object.freeze([...completed]) + this.unconfirmed = Object.freeze([...unconfirmed]) + } +} + +/** One confirmation, ordered saves, and immediate stop on failure or a stale host. */ +export async function requestNoteBatch( + host: NoteActionHost, + requestedPaths: readonly string[], + action: NoteBatchAction +): Promise { + const paths = [...new Set(requestedPaths)] + const completed: string[] = [] + const result = (status: NoteActionResult): NoteBatchResult => Object.freeze({ + status, completed: Object.freeze([...completed]), unconfirmed: Object.freeze(paths.slice(completed.length)) + }) + if (!paths.length || paths.some(path => formDirContaining(path))) return result('unavailable') + const allowed = (note: NoteMeta) => action === 'restore' + ? note.folder === 'archive' || note.folder === 'trash' + : action === 'delete' ? note.folder === 'trash' + : action === 'archive' ? note.folder === 'inbox' || note.folder === 'quick' + : note.folder !== 'trash' + try { + const status = await requestNoteAction(host, paths[0], async (state, first, isCurrent) => { + const valid = () => paths.every(path => { + const note = useStore.getState().notes.find(note => note.path === path) + return note && allowed(note) + }) + if (!valid()) return 'unavailable' + let destination: ReturnType | null = null + if (action === 'move') { + const target = await promptApp({ + ...buildMoveNotePrompt({ ...first, title: `${paths.length} notes` }, state.folders.filter(folder => !formDirContaining(folder.subpath))), + initialValue: [first.folder === 'archive' ? 'archive' : 'inbox', noteFolderSubpath(first, state.vaultSettings)].filter(Boolean).join('/'), + validate: validateDestination + }) + if (!target || validateDestination(target)) return 'cancelled' + destination = parseMoveNoteTarget(target) + } else if (action === 'archive') { + if (!(await state.confirmArchiveNotes(paths))) return 'cancelled' + } else if (action !== 'restore') { + const deleting = action === 'delete' + if (!(await confirmApp({ + title: deleting ? `Delete ${paths.length} notes permanently?` : `Move ${paths.length} notes to Trash?`, + description: deleting ? 'This cannot be undone.' : state.vault?.temporary + ? 'Restore these files using your system file manager.' : 'You can restore these notes from the Trash view.', + confirmLabel: deleting ? 'Delete permanently' : 'Move to Trash', + danger: deleting + }))) return 'cancelled' + } + if (!isCurrent() || !valid()) return 'stale' + for (const path of paths) { + const current = useStore.getState().notes.find(note => note.path === path) + if (!isCurrent() || !current || !allowed(current)) return 'stale' + if (destination) { + if (destination.folder !== current.folder || destination.subpath !== noteFolderSubpath(current, state.vaultSettings)) + await useStore.getState().moveNote(path, destination.folder, destination.subpath, isCurrent) + } else { + await useStore.getState().changeNoteLifecycle(path, action as Exclude, isCurrent) + } + if (!isCurrent()) return 'stale' + completed.push(path) + } + return 'completed' + }, allowed) + return result(status) + } catch (error) { + throw new NoteBatchError(completed, paths.slice(completed.length), error) + } +} + + +/** Permanently clear the configured Trash with one save/lock operation. */ +export async function requestEmptyTrash(host: NoteActionHost): Promise { + if (pending || getPromptRequest() || getConfirmRequest() || !useStore.getState().vault) return 'unavailable' + const isCurrent = captureNoteActionContext(host) + if (!isCurrent()) return 'unavailable' + pending = true + try { + if (!(await confirmApp({title:'Empty Trash permanently?',description:'All files in Trash will be deleted. This cannot be undone.',confirmLabel:'Empty trash',danger:true}))) return 'cancelled' + if (!isCurrent()) return 'stale' + await useStore.getState().emptyTrash(isCurrent) + return isCurrent() ? 'completed' : 'stale' + } finally {pending=false} +} diff --git a/packages/app-core/src/public-host-api.test.ts b/packages/app-core/src/public-host-api.test.ts new file mode 100644 index 00000000..375633c4 --- /dev/null +++ b/packages/app-core/src/public-host-api.test.ts @@ -0,0 +1,275 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { VaultTask } from '@bridge-contract/tasks' + +beforeEach(() => { vi.resetModules(); localStorage.clear() }) +async function setup() { + Object.defineProperty(window, 'zen', { configurable: true, value: { + getCapabilities: () => ({}), getAppInfo: () => ({ runtime: 'web' }), platformSync: () => 'linux' + } }) + const { useStore } = await import('./store') + return { useStore, tasks: await import('./tasks'), settings: await import('./settings'), workspace: await import('./workspace') } +} +function task(): VaultTask { + return { id: 'inbox/One.md#0', sourcePath: 'inbox/One.md', noteTitle: 'One', noteFolder: 'inbox', + lineNumber: 0, taskIndex: 0, rawText: '- [/] Write #work @status:ready', content: 'Write', + checked: false, forwarded: false, cancelled: false, inProgress: true, waiting: false, + tags: ['work'], fields: { status: 'ready' } } +} +describe('public host APIs', () => { + it('publishes frozen task data and ignores unrelated updates; disposal stops notifications', async () => { + const s = await setup(), original = task() + s.useStore.setState({ vaultTasks: [original] }) + const first = s.tasks.getTasksSnapshot(), listener = vi.fn() + const dispose = s.tasks.subscribeTasks(listener) + expect(() => (first.tasks[0].tags as string[]).push('wrong')).toThrow() + expect(() => Object.assign(first.tasks[0].fields!, { status: 'wrong' })).toThrow() + s.useStore.setState({ searchOpen: true }) + expect(s.tasks.getTasksSnapshot()).toBe(first) + s.useStore.setState({ tasksLoading: true }) + expect(listener).toHaveBeenCalledTimes(1) + dispose(); s.useStore.setState({ tasksLoading: false }) + expect(listener).toHaveBeenCalledTimes(1) + expect(original.tags).toEqual(['work']) + }) + it('moves the current task with desktop semantics and rejects stale host/grouping', async () => { + const s = await setup(), applyTaskMutation = vi.fn().mockResolvedValue(undefined), original = task() + s.useStore.setState({ vault: { root: '/test', name: 'Test' }, vaultTasks: [original], kanbanGroupBy: 'status', applyTaskMutation }) + expect(await s.tasks.moveTaskToColumn({ isCurrent: () => false }, original.id, 'status', 'today')).toBe(false) + expect(await s.tasks.moveTaskToColumn({ isCurrent: () => true }, original.id, 'priority', 'high')).toBe(false) + expect(applyTaskMutation).not.toHaveBeenCalled() + expect(await s.tasks.moveTaskToColumn({ isCurrent: () => true }, original.id, 'status', 'today')).toBe(true) + const changes = applyTaskMutation.mock.calls[0][1] + expect(changes).toContainEqual({ kind: 'set-in-progress', inProgress: false }) + expect(changes).toContainEqual({ kind: 'set-checked', checked: false }) + const today = new Date() + expect(changes).toContainEqual({ kind: 'set-due', due: `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}` }) + }) + it('exposes profile display fields without retaining credentials or mutable entries', async () => { + const s = await setup() + const profile = { id: 'one', name: 'Private server', baseUrl: 'https://example.test', vaultPath: null, + lastConnectedAt: null, hasCredential: true, authToken: 'never expose' } + s.useStore.setState({ remoteWorkspaceProfiles: [profile] }) + const publicProfile = s.workspace.getWorkspaceSnapshot().remoteProfiles[0] + expect(publicProfile).not.toHaveProperty('authToken') + expect(Object.isFrozen(publicProfile)).toBe(true) + expect(publicProfile).not.toBe(profile) + }) + it('clamps finite font gestures and publishes only changed settings', async () => { + const s = await setup(), setEditorFontSize = vi.fn() + s.useStore.setState({ setEditorFontSize }) + s.settings.setEditorFontSize(Number.NaN) + s.settings.setEditorFontSize(Infinity) + expect(setEditorFontSize).not.toHaveBeenCalled() + s.settings.setEditorFontSize(100); s.settings.setEditorFontSize(1) + expect(setEditorFontSize.mock.calls).toEqual([[28],[12]]) + const first = s.settings.getSettingsSnapshot() + s.useStore.setState({ sidebarOpen: false }) + expect(s.settings.getSettingsSnapshot()).toBe(first) + s.settings.setSettingsVisible(true) + expect(s.settings.getSettingsSnapshot().open).toBe(true) + }) + it('does not let a second host dialog replace an unresolved prompt', async () => { + await setup() + const dialogs = await import('./dialogs'), requests = await import('./lib/prompt-requests') + const pending = dialogs.prompt({ title: 'First' }), request = requests.getPromptRequest()! + expect(await dialogs.prompt({ title: 'Second' })).toBeNull() + expect(await dialogs.confirm({ title: 'Second' })).toBe(false) + expect(requests.getPromptRequest()).toBe(request) + requests.settlePromptRequest(request, 'answer') + expect(await pending).toBe('answer') + }) + it('rechecks command availability at invocation instead of retaining stale closures', async () => { + const s = await setup(), commands = await import('./commands') + expect(await commands.runAppCommand('not-a-command')).toBe(false) + const archiveActive = vi.fn() + s.useStore.setState({ archiveActive }) + expect(commands.getAppCommands().find(c => c.id === 'note.archive')?.available).toBe(false) + expect(await commands.runAppCommand('note.archive')).toBe(false) + expect(archiveActive).not.toHaveBeenCalled() + commands.showSearch() + expect(s.useStore.getState().searchOpen).toBe(true) + }) +}) + +describe('workspace transition reservation', () => { + it('reserves the entire save drain and bridge selection against competing transitions', async () => { + const s = await setup() + let release!: () => void + const drain = new Promise(resolve => { release = resolve }) + const open = vi.fn().mockResolvedValue(null), disconnect = vi.fn() + Object.assign(window.zen, { openLocalVault: open, disconnectRemoteWorkspace: disconnect }) + s.useStore.setState({ vault: { root: '/current', name: 'Current' }, flushDirtyNotes: () => drain }) + const pending = s.workspace.openLocalVault('/first') + await s.workspace.openLocalVault('/second') + await s.workspace.disconnectRemoteWorkspace() + expect(open).not.toHaveBeenCalled() + expect(disconnect).not.toHaveBeenCalled() + release(); await pending + expect(open.mock.calls).toEqual([['/first']]) + await s.workspace.openLocalVault('/second') + expect(open.mock.calls).toEqual([['/first'], ['/second']]) + }) + it('does not dispatch navigation during a pending host switch or resume a read after cancellation', async () => { + const s = await setup(), navigation = await import('./navigation') + let release!: () => void + const selection = new Promise(resolve => { release = () => resolve(null) }) + const read = vi.fn() + Object.assign(window.zen, { openLocalVault: () => selection, readNote: read }) + s.useStore.setState({ vault: { root: '/current', name: 'Current' }, flushDirtyNotes: async () => {} }) + const pending = s.workspace.openLocalVault('/next') + await navigation.openNote('inbox/One.md') + expect(read).not.toHaveBeenCalled() + release(); await pending + }) + it('previews font size in memory and persists once when the gesture completes', async () => { + const s = await setup(), persist = vi.fn() + s.useStore.setState({ setEditorFontSize: persist }) + s.settings.setEditorFontSize(17.5, { persist: false }) + s.settings.setEditorFontSize(30, { persist: false }) + expect(s.settings.getSettingsSnapshot().editorFontSize).toBe(28) + expect(persist).not.toHaveBeenCalled() + s.settings.setEditorFontSize(s.settings.getSettingsSnapshot().editorFontSize) + expect(persist.mock.calls).toEqual([[28]]) + }) +}) + +describe('workspace input safety', () => { + it('locks editor and database input through a cancelled picker, then releases it', async () => { + const s = await setup(), locks = await import('./lib/note-lifecycle-lock') + const vault = { root: '/current', name: 'Current' } + let release!: () => void + const picker = new Promise(resolve => { release = () => resolve(null) }) + Object.assign(window.zen, { openLocalVault: vi.fn(() => picker) }) + s.useStore.setState({ vault, flushDirtyNotes: async () => {} }) + const pending = s.workspace.openLocalVault('/next') + await vi.waitFor(() => expect(window.zen.openLocalVault).toHaveBeenCalled()) + expect(locks.isNoteEditingLocked(vault, 'inbox/Newly opened.md')).toBe(true) + expect(locks.isNoteEditingLocked(vault, 'inbox/Projects.base/data.csv')).toBe(true) + expect(s.workspace.getWorkspaceSnapshot().transitioning).toBe(true) + release(); await pending + expect(locks.isNoteEditingLocked(vault, 'inbox/Newly opened.md')).toBe(false) + expect(s.workspace.getWorkspaceSnapshot().transitioning).toBe(false) + }) + it('clears invalidated navigation markers even if the host picker cancels', async () => { + const s = await setup(), navigation = await import('./navigation') + let finishRead!: (value: unknown) => void + const read = new Promise(resolve => { finishRead = resolve }) + Object.assign(window.zen, { readNote: vi.fn(() => read), openLocalVault: async () => null }) + s.useStore.setState({ vault: { root: '/current', name: 'Current' }, flushDirtyNotes: async () => {} }) + const pending = navigation.openNote('inbox/One.md') + expect(s.useStore.getState().loadingNote).toBe(true) + await s.workspace.openLocalVault('/next') + finishRead({ path: 'inbox/One.md', body: 'Old body' }) + await pending + expect(s.useStore.getState()).toMatchObject({ loadingNote: false, pendingJumpLocation: null, selectedPath: null }) + }) + it('leaves back, Home, daily creation and app pages alone while a transition is reserved', async () => { + const s = await setup(), navigation = await import('./navigation') + let release!: () => void + const picker = new Promise(resolve => { release = () => resolve(null) }) + Object.assign(window.zen, { openLocalVault: () => picker, readNote: vi.fn() }) + const daily = vi.fn(), tasks = vi.fn() + s.useStore.setState({ vault: { root: '/current', name: 'Current' }, flushDirtyNotes: async () => {}, + selectedPath: 'inbox/Current.md', noteBackstack: [{ path: 'inbox/Old.md' } as never], + openTodayDailyNote: daily, openTasksView: tasks }) + const pending = s.workspace.openLocalVault('/next') + await navigation.goBack(); navigation.goHome() + await navigation.openTodayDailyNote(); await navigation.openAppPage('tasks') + expect(s.useStore.getState().selectedPath).toBe('inbox/Current.md') + expect(window.zen.readNote).not.toHaveBeenCalled() + expect(daily).not.toHaveBeenCalled(); expect(tasks).not.toHaveBeenCalled() + release(); await pending + }) +}) + +describe('comments during workspace selection', () => { + it('drains an existing comment write and rejects new comments while the host switches', async () => { + const s = await setup() + let finishWrite!: (value: unknown[]) => void, finishOpen!: () => void + const write = new Promise(resolve => { finishWrite = resolve }) + const opening = new Promise(resolve => { finishOpen = () => resolve(null) }) + Object.assign(window.zen, { writeNoteComments: vi.fn(() => write), openLocalVault: vi.fn(() => opening) }) + s.useStore.setState({ vault: { root: '/current', name: 'Current' }, noteComments: { 'inbox/One.md': [] } }) + const comment = s.useStore.getState().addNoteComment({ notePath: 'inbox/One.md', body: 'Keep this comment', anchor: null } as never) + const switching = s.workspace.openLocalVault('/next') + await Promise.resolve() + expect(window.zen.openLocalVault).not.toHaveBeenCalled() + finishWrite([]); await comment + await vi.waitFor(() => expect(window.zen.openLocalVault).toHaveBeenCalled()) + expect(await s.useStore.getState().addNoteComment({ notePath: 'inbox/One.md', body: 'Too late', anchor: null } as never)).toBeNull() + expect(window.zen.writeNoteComments).toHaveBeenCalledTimes(1) + finishOpen(); await switching + }) +}) + +describe('host vault relocation', () => { + it('reserves before draining saves and locks input until native relocation finishes', async () => { + const s = await setup(), locks = await import('./lib/note-lifecycle-lock') + const vault = { root: '/old', name: 'Old' }, events: string[] = [] + let finishDrain!: () => void, finishMove!: () => void + const drain = new Promise(resolve => { finishDrain = resolve }) + const moving = new Promise(resolve => { finishMove = resolve }) + s.useStore.setState({ vault, flushDirtyNotes: async () => { events.push('drain'); await drain } }) + const open = vi.fn().mockResolvedValue(null) + Object.assign(window.zen, { openLocalVault: open }) + const pending = s.workspace.relocateLocalVault({ + move: async () => { events.push('move'); await moving }, rollback: vi.fn() + }) + await s.workspace.openLocalVault('/other') + expect(open).not.toHaveBeenCalled() + expect(events).toEqual(['drain']) + finishDrain() + await vi.waitFor(() => expect(events).toContain('move')) + expect(events).toEqual(['drain', 'drain', 'move']) + expect(locks.isNoteEditingLocked(vault, 'inbox/One.md')).toBe(true) + await expect(s.workspace.relocateLocalVault({ move: vi.fn(), rollback: vi.fn() })).rejects.toThrow('Wait') + finishMove(); await pending + expect(locks.isNoteEditingLocked(vault, 'inbox/One.md')).toBe(false) + }) + it('rolls native storage back and restores the saved workspace when reopening fails', async () => { + const s = await setup(), events: string[] = [] + const vault = { root: '/old', name: 'Old' } + s.useStore.setState({ vault, selectedPath: 'inbox/Keep.md', noteContents: { 'inbox/Keep.md': { body: 'Exact café \n' } as never }, + flushDirtyNotes: async () => {}, refreshLocalVaults: async () => [] }) + Object.assign(window.zen, { openLocalVault: async (root: string) => { + events.push(`open:${root}`) + if (root === 'new-token') throw new Error('Provider unavailable') + return vault + } }) + await expect(s.workspace.relocateLocalVault({ + reopen: { source: 'old-token', destination: 'new-token' }, + move: async () => { events.push('move') }, rollback: async () => { events.push('rollback') } + })).rejects.toThrow('Provider unavailable') + expect(events).toEqual(['move', 'open:new-token', 'rollback', 'open:old-token']) + expect(s.useStore.getState().vault).toBe(vault) + expect(s.useStore.getState().selectedPath).toBe('inbox/Keep.md') + expect(s.useStore.getState().noteContents['inbox/Keep.md'].body).toBe('Exact café \n') + expect(s.workspace.getWorkspaceSnapshot().transitioning).toBe(false) + }) + it('stops active vault writers and surfaces both errors when native rollback fails', async () => { + const s = await setup() + s.useStore.setState({ vault: { root: '/old', name: 'Old' }, flushDirtyNotes: async () => {} }) + Object.assign(window.zen, { openLocalVault: async () => { throw new Error('reopen failed') } }) + await expect(s.workspace.relocateLocalVault({ + reopen: { source: 'old', destination: 'new' }, move: async () => {}, + rollback: async () => { throw new Error('rollback failed') } + })).rejects.toThrow('relocation and recovery failed') + expect(s.useStore.getState().vault).toBeNull() + expect(s.workspace.getWorkspaceSnapshot().restored).toBe(false) + expect(s.useStore.getState().workspaceSetupError).toContain('checking its storage location') + }) + it('keeps host generation invalidated after a cancelled or failed switch', async () => { + const s = await setup() + s.useStore.setState({ vault: { root: '/old', name: 'Old' }, flushDirtyNotes: async () => {} }) + const original = s.workspace.getWorkspaceSnapshot() + Object.assign(window.zen, { openLocalVault: async () => null }) + await s.workspace.openLocalVault('/cancelled') + const cancelled = s.workspace.getWorkspaceSnapshot() + expect(cancelled.generation).toBeGreaterThan(original.generation) + expect(cancelled.transitioning).toBe(false) + Object.assign(window.zen, { openLocalVault: async () => { throw new Error('failed') } }) + await s.workspace.openLocalVault('/failed') + expect(s.workspace.getWorkspaceSnapshot().generation).toBeGreaterThan(cancelled.generation) + }) +}) diff --git a/packages/app-core/src/settings.ts b/packages/app-core/src/settings.ts new file mode 100644 index 00000000..4889e31c --- /dev/null +++ b/packages/app-core/src/settings.ts @@ -0,0 +1,38 @@ +import { useSyncExternalStore } from 'react' +import { useStore } from './store' + +export interface SettingsSnapshot { + readonly themeId: string + readonly themeMode: 'light' | 'dark' | 'auto' + readonly open: boolean + readonly editorFontSize: number + readonly dailyNotesEnabled: boolean + readonly calendarAvailable: boolean +} +let snapshot: SettingsSnapshot | undefined +export function getSettingsSnapshot(): SettingsSnapshot { + const state = useStore.getState() + const next = { themeId: state.themeId, themeMode: state.themeMode, open: state.settingsOpen, editorFontSize: state.editorFontSize, + dailyNotesEnabled: state.vaultSettings.dailyNotes.enabled, + calendarAvailable: state.vaultSettings.dailyNotes.enabled || state.vaultSettings.weeklyNotes.enabled } + if (!snapshot || (Object.keys(next) as Array).some(key => snapshot![key] !== next[key])) + snapshot = Object.freeze(next) + return snapshot +} +export function subscribeSettings(listener: (next: SettingsSnapshot, previous: SettingsSnapshot) => void): () => void { + let previous = getSettingsSnapshot() + return useStore.subscribe(() => { + const next = getSettingsSnapshot() + if (next === previous) return + const before = previous; previous = next; listener(next, before) + }) +} +function subscribeReact(notify: () => void): () => void { return subscribeSettings(() => notify()) } +export function useSettingsSnapshot(): SettingsSnapshot { return useSyncExternalStore(subscribeReact, getSettingsSnapshot, getSettingsSnapshot) } +export function setSettingsVisible(open: boolean): void { useStore.getState().setSettingsOpen(open) } +export function setEditorFontSize(size: number, options?: { persist?: boolean }): void { + if (!Number.isFinite(size)) return + const clamped = Math.max(12, Math.min(28, Math.round(size))) + if (options?.persist === false) useStore.setState({ editorFontSize: clamped }) + else useStore.getState().setEditorFontSize(clamped) +} diff --git a/packages/app-core/src/shell.test.ts b/packages/app-core/src/shell.test.ts new file mode 100644 index 00000000..d756119b --- /dev/null +++ b/packages/app-core/src/shell.test.ts @@ -0,0 +1,326 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteMeta } from '@bridge-contract/ipc' + +const disposers: Array<() => void> = [] +const note = (path: string, extra: Partial = {}): NoteMeta => ({ + path, + title: path.split('/').pop()!.replace(/\.md$/, ''), + folder: 'inbox', + createdAt: 0, + updatedAt: 0, + siblingOrder: 0, + size: 10, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: 'Private preview', + ...extra +}) + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { + configurable: true, + value: { getCapabilities: () => ({}) } + }) +}) +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose() +}) + +async function setup(notes = [note('inbox/One.md')]) { + const { useStore } = await import('./store') + const shell = await import('./shell') + useStore.setState({ + notes, + vault: { root: '/test', name: 'Test' }, + workspaceRestored: true + }) + return { useStore, ...shell } +} + +describe('public shell snapshots', () => { + it('exposes frozen, copied identity metadata without bodies, credentials, or store internals', async () => { + const s = await setup() + s.useStore.setState({ selectedPath: 'inbox/One.md' }) + const snapshot = s.getShellSnapshot() + expect(snapshot.selectedNote).toBe(snapshot.notes[0]) + expect(snapshot.notes[0]).toEqual({ + path: 'inbox/One.md', + title: 'One', + folder: 'inbox', + directory: '', + createdAt: 0, + updatedAt: 0 + }) + expect(snapshot).not.toHaveProperty('activeNote') + expect(snapshot).not.toHaveProperty('remoteWorkspaceProfiles') + expect(snapshot).not.toHaveProperty('paneLayout') + expect(snapshot).not.toHaveProperty('editorViewRef') + expect(snapshot.vault).not.toBe(s.useStore.getState().vault) + expect(snapshot.notes[0]).not.toBe(s.useStore.getState().notes[0]) + for (const value of [ + snapshot, + snapshot.notes, + snapshot.notes[0], + snapshot.vault + ]) + expect(Object.isFrozen(value)).toBe(true) + expect(() => + Object.assign(snapshot.notes[0], { title: 'Changed' }) + ).toThrow() + expect(s.useStore.getState().notes[0].title).toBe('One') + }) + + it('keeps snapshot identity across repeated reads and unrelated editor changes', async () => { + const s = await setup() + const before = s.getShellSnapshot() + s.useStore.setState({ editorFontSize: 25, activeDirty: true }) + expect(s.getShellSnapshot()).toBe(before) + expect(s.getShellSnapshot()).toBe(before) + const settings = s.useStore.getState().vaultSettings + s.useStore.setState({ + vaultSettings: { + ...settings, + dailyNotes: { + ...settings.dailyNotes, + enabled: !settings.dailyNotes.enabled + } + } + }) + expect(s.getShellSnapshot()).toBe(before) + }) + + it('copies changed metadata without mutating previously delivered snapshots', async () => { + const s = await setup() + const before = s.getShellSnapshot() + s.useStore.setState({ + notes: [note('inbox/One.md', { title: 'Renamed', updatedAt: 8 })] + }) + const after = s.getShellSnapshot() + expect(after.notes[0].title).toBe('Renamed') + expect(before.notes[0].title).toBe('One') + expect(after.vault).toBe(before.vault) + }) + + it('reports Home, virtual pages, and removed notes without inventing a selected note', async () => { + const s = await setup() + for (const selectedPath of [null, 'zen://help', 'inbox/Missing.md']) { + s.useStore.setState({ selectedPath }) + expect(s.getShellSnapshot()).toMatchObject({ + selectedPath, + selectedNote: null + }) + } + }) + + it('notifies only on public changes with coherent previous snapshots and supports disposal', async () => { + const s = await setup() + const before = s.getShellSnapshot() + const listener = vi.fn() + const dispose = s.subscribeShell(listener) + disposers.push(dispose) + expect(listener).not.toHaveBeenCalled() + s.useStore.setState({ activeDirty: true }) + expect(listener).not.toHaveBeenCalled() + s.useStore.setState({ selectedPath: 'inbox/One.md' }) + expect(listener).toHaveBeenCalledExactlyOnceWith( + s.getShellSnapshot(), + before + ) + expect(s.getShellSnapshot().notes).toBe(before.notes) + dispose() + s.useStore.setState({ selectedPath: null }) + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('tracks workspace replacement and restoration without exposing mutable vault objects', async () => { + const s = await setup() + const before = s.getShellSnapshot() + s.useStore.setState({ + vault: { root: '/other', name: 'Other', temporary: true }, + notes: [], + workspaceMode: 'remote', + workspaceRestored: false + }) + expect(s.getShellSnapshot()).toMatchObject({ + vault: { root: '/other', name: 'Other', temporary: true }, + notes: [], + workspaceMode: 'remote', + workspaceRestored: false + }) + expect(before.vault?.root).toBe('/test') + s.useStore.setState({ vault: null }) + expect(s.getShellSnapshot().vault).toBeNull() + }) + + it('observes the current state after an earlier subscriber corrects a transition', async () => { + const s = await setup() + disposers.push( + s.useStore.subscribe((state) => { + if (state.selectedPath === 'inbox/One.md') + s.useStore.setState({ selectedPath: null }) + }) + ) + const listener = vi.fn() + disposers.push(s.subscribeShell(listener)) + s.useStore.setState({ selectedPath: 'inbox/One.md' }) + expect(listener).not.toHaveBeenCalled() + expect(s.getShellSnapshot().selectedPath).toBeNull() + }) + + it('keeps previous snapshots coherent when a listener causes another public change', async () => { + const s = await setup() + const transitions: Array<[string | null, string | null]> = [] + disposers.push( + s.subscribeShell((next, previous) => { + transitions.push([previous.selectedPath, next.selectedPath]) + if (next.selectedPath) s.useStore.setState({ selectedPath: null }) + }) + ) + s.useStore.setState({ selectedPath: 'inbox/One.md' }) + expect(transitions).toEqual([ + [null, 'inbox/One.md'], + ['inbox/One.md', null] + ]) + }) + + it('recomputes folder-relative directories when system folders or primary location change', async () => { + const s = await setup([ + note('Notes/Work/One.md'), + note('Saved/Two.md', { folder: 'archive' }) + ]) + const settings = s.useStore.getState().vaultSettings + s.useStore.setState({ + vaultSettings: { + ...settings, + systemFolderPaths: { + ...settings.systemFolderPaths, + inbox: 'Notes', + archive: 'Saved' + } + } + }) + expect(s.getShellSnapshot().notes.map((n) => n.directory)).toEqual([ + 'Work', + '' + ]) + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation: 'root' + } + }) + expect(s.getShellSnapshot().notes.map((n) => n.directory)).toEqual([ + 'Notes/Work', + '' + ]) + }) +}) + +describe('public Browse ordering', () => { + const names = (rows: readonly { title: string }[]) => rows.map((n) => n.title) + it.each([ + ['name-asc', ['Note 2', 'Note 10', 'Note 20']], + ['name-desc', ['Note 20', 'Note 10', 'Note 2']], + ['updated-asc', ['Note 10', 'Note 2', 'Note 20']], + ['updated-desc', ['Note 20', 'Note 2', 'Note 10']], + ['created-asc', ['Note 20', 'Note 10', 'Note 2']], + ['created-desc', ['Note 2', 'Note 10', 'Note 20']], + ['none', ['Note 20', 'Note 2', 'Note 10']], + ['manual', ['Note 20', 'Note 2', 'Note 10']] + ] as const)( + 'preserves mobile %s sorting', + async (noteSortOrder, expected) => { + const s = await setup([ + note('inbox/Note 10.md', { updatedAt: 1, createdAt: 2 }), + note('inbox/Note 2.md', { updatedAt: 2, createdAt: 3 }), + note('inbox/Note 20.md', { updatedAt: 3, createdAt: 1 }) + ]) + s.useStore.setState({ noteSortOrder }) + const snapshot = s.getShellSnapshot() + expect(names(s.getBrowseNotes(snapshot))).toEqual(expected) + expect(names(snapshot.notes)).toEqual(['Note 10', 'Note 2', 'Note 20']) + } + ) + + it('pins first while retaining sorted order within both groups and input order for ties', async () => { + const s = await setup( + ['C', 'A', 'B', 'D'].map((name) => note(`inbox/${name}.md`)) + ) + const pins = ['inbox/B.md', 'inbox/C.md', 'inbox/B.md', 'inbox/Gone.md'] + expect(names(s.getBrowseNotes(s.getShellSnapshot(), '', pins))).toEqual([ + 'C', + 'B', + 'A', + 'D' + ]) + s.useStore.setState({ noteSortOrder: 'name-asc' }) + const rows = s.getBrowseNotes(s.getShellSnapshot(), '', pins) + expect(names(rows)).toEqual(['B', 'C', 'A', 'D']) + expect(Object.isFrozen(rows)).toBe(true) + expect(pins).toHaveLength(4) + }) + + it('keeps navigation in the immediate primary folder and stops at both ends', async () => { + const s = await setup([ + note('inbox/Work/A.md'), + note('inbox/Work/B.md'), + note('inbox/Work/nested/C.md'), + note('inbox/D.md'), + note('archive/E.md', { folder: 'archive' }), + note('quick/F.md', { folder: 'quick' }) + ]) + const snapshot = s.getShellSnapshot() + expect(names(s.getBrowseNotes(snapshot, 'Work'))).toEqual(['A', 'B']) + expect(s.getAdjacentNotePath(snapshot, 'inbox/Work/A.md', 'next')).toBe( + 'inbox/Work/B.md' + ) + expect(s.getAdjacentNotePath(snapshot, 'inbox/Work/B.md', 'previous')).toBe( + 'inbox/Work/A.md' + ) + expect( + s.getAdjacentNotePath(snapshot, 'inbox/Work/A.md', 'previous') + ).toBeNull() + expect( + s.getAdjacentNotePath(snapshot, 'inbox/Work/B.md', 'next') + ).toBeNull() + for (const path of [ + 'archive/E.md', + 'quick/F.md', + 'zen://help', + 'missing.md' + ]) + expect(s.getAdjacentNotePath(snapshot, path, 'next')).toBeNull() + expect( + s.getAdjacentNotePath(snapshot, 'inbox/Work/B.md', 'next', [ + 'inbox/Work/B.md' + ]) + ).toBe('inbox/Work/A.md') + }) + + it('excludes database records at every depth while retaining ordinary similarly named folders', async () => { + const s = await setup([ + note('inbox/People.base/One.md'), + note('inbox/People.base/pages/Two.md'), + note('inbox/Work/PEOPLE.BASE/pages/Three.md'), + note('inbox/People.base-notes/Four.md') + ]) + const snapshot = s.getShellSnapshot() + for (const path of [ + 'People.base', + 'People.base/pages', + 'Work/PEOPLE.BASE/pages' + ]) + expect(s.getBrowseNotes(snapshot, path)).toEqual([]) + for (const n of snapshot.notes.slice(0, 3)) + expect(s.getAdjacentNotePath(snapshot, n.path, 'next')).toBeNull() + expect(names(s.getBrowseNotes(snapshot, 'People.base-notes'))).toEqual([ + 'Four' + ]) + }) +}) diff --git a/packages/app-core/src/shell.ts b/packages/app-core/src/shell.ts new file mode 100644 index 00000000..275ff31c --- /dev/null +++ b/packages/app-core/src/shell.ts @@ -0,0 +1,203 @@ +import { noteTagsForCount } from './lib/tags' +import { resolveTypstPreambleFolder } from './lib/typst-preamble' +import { useSyncExternalStore } from 'react' +import type { + NoteFolder, + NoteMeta, + VaultInfo, + WorkspaceMode +} from '@bridge-contract/ipc' +import { formDirContaining } from '@shared/databases' +import { resolveFolderPath } from '@shared/system-folder-paths' +import { useStore } from './store' +import { parentDirOf } from './lib/manual-order' +import { browseNoteComparator, type NoteSortOrder } from './lib/note-order' +import { notePathWithinFolder } from './lib/vault-layout' + +export type { NoteSortOrder } from './lib/note-order' + +export interface ShellNote { + readonly path: string + readonly title: string + readonly folder: NoteFolder + /** Parent directory relative to this note's logical folder; empty at its root. */ + readonly directory: string + readonly createdAt: number + readonly updatedAt: number +} + +export interface ShellSnapshot { + /** Display/change metadata. Persist native state under the host's stable vault token. */ + readonly vault: Readonly | null + readonly workspaceMode: WorkspaceMode + /** Workspace restoration state; native note-index readiness remains host-owned. */ + readonly workspaceRestored: boolean + readonly notes: readonly ShellNote[] + readonly selectedPath: string | null + /** Null for Home, virtual pages, or a path absent from the note index. */ + readonly selectedNote: ShellNote | null + readonly canGoBack: boolean + readonly canGoForward: boolean + readonly noteSortOrder: NoteSortOrder +} + +let notesSource: readonly NoteMeta[] | undefined +let notesLayout = '' +let notes: readonly ShellNote[] = Object.freeze([]) +let vault: ShellSnapshot['vault'] = null +let snapshot: ShellSnapshot | undefined + +/** Read frozen shell metadata, without note bodies, credentials, or mutable store values. */ +export function getShellSnapshot(): ShellSnapshot { + const state = useStore.getState() + const settings = state.vaultSettings + const layout = JSON.stringify([ + settings.primaryNotesLocation, + ...(['inbox', 'quick', 'archive', 'trash'] as const).map((folder) => + resolveFolderPath(folder, settings.systemFolderPaths) + ) + ]) + if (notesSource !== state.notes || notesLayout !== layout) { + notesSource = state.notes + notesLayout = layout + notes = Object.freeze( + state.notes.map((note) => + Object.freeze({ + path: note.path, + title: note.title, + folder: note.folder, + directory: parentDirOf( + notePathWithinFolder(note.path, note.folder, settings) + ), + createdAt: note.createdAt, + updatedAt: note.updatedAt + }) + ) + ) + } + if (!state.vault) vault = null + else if ( + vault?.root !== state.vault.root || + vault.name !== state.vault.name || + vault.temporary !== state.vault.temporary + ) { + vault = Object.freeze({ + root: state.vault.root, + name: state.vault.name, + temporary: state.vault.temporary + }) + } + const next: ShellSnapshot = { + vault, + notes, + workspaceMode: state.workspaceMode, + workspaceRestored: state.workspaceRestored && !state.workspaceTransitioning, + selectedPath: state.selectedPath, + selectedNote: + snapshot?.notes === notes && snapshot.selectedPath === state.selectedPath + ? snapshot.selectedNote + : (notes.find((note) => note.path === state.selectedPath) ?? null), + canGoBack: state.noteBackstack.length > 0, + canGoForward: state.noteForwardstack.length > 0, + noteSortOrder: state.noteSortOrder + } + if ( + !snapshot || + (Object.keys(next) as Array).some( + (key) => next[key] !== snapshot![key] + ) + ) { + snapshot = Object.freeze(next) + } + return snapshot +} + +/** Notify after a public snapshot changes. Does not emit an initial notification. */ +export function subscribeShell( + listener: (snapshot: ShellSnapshot, previous: ShellSnapshot) => void +): () => void { + let previous = getShellSnapshot() + return useStore.subscribe(() => { + // An earlier subscriber can synchronously correct a transition, such as the + // Home guard after a rescan. Read current state rather than a stale event. + const next = getShellSnapshot() + if (next === previous) return + const before = previous + previous = next + listener(next, before) + }) +} + +function subscribeReact(notify: () => void): () => void { + return subscribeShell(() => notify()) +} + +export function useShellSnapshot(): ShellSnapshot { + return useSyncExternalStore( + subscribeReact, + getShellSnapshot, + getShellSnapshot + ) +} + +/** Immediate primary-folder notes in mobile Browse order. Pins remain host-owned. */ +export function getBrowseNotes( + snapshot: Pick, + directory = '', + pinnedPaths: readonly string[] = [] +): readonly ShellNote[] { + if (formDirContaining(directory)) return Object.freeze([]) + const rows = snapshot.notes + .filter((note) => note.folder === 'inbox' && note.directory === directory) + .sort(browseNoteComparator(snapshot.noteSortOrder)) + const pins = new Set(pinnedPaths) + return Object.freeze([ + ...rows.filter((note) => pins.has(note.path)), + ...rows.filter((note) => !pins.has(note.path)) + ]) +} + +/** Find a Browse sibling without opening it or wrapping at either end. */ +export function getAdjacentNotePath( + snapshot: ShellSnapshot, + path: string, + direction: 'previous' | 'next', + pinnedPaths: readonly string[] = [] +): string | null { + const note = snapshot.notes.find((note) => note.path === path) + if (!note || note.folder !== 'inbox') return null + const rows = getBrowseNotes(snapshot, note.directory, pinnedPaths) + const index = rows.findIndex((note) => note.path === path) + return index < 0 + ? null + : (rows[index + (direction === 'next' ? 1 : -1)]?.path ?? null) +} + + +export interface TagPresenceSnapshot { + readonly vaultRoot: string | null + readonly hasTags: boolean +} +let tagNotes: unknown, tagActive: unknown, tagFolder: string | undefined +let tagPresence: TagPresenceSnapshot | undefined +/** Tag presence includes the live editor and excludes Typst preambles. */ +export function getTagPresenceSnapshot(): TagPresenceSnapshot { + const state = useStore.getState() + const folder = resolveTypstPreambleFolder(state.vaultSettings.typstPreambles?.folder) + if (tagNotes === state.notes && tagActive === state.activeNote && tagFolder === folder && tagPresence?.vaultRoot === (state.vault?.root ?? null)) return tagPresence! + tagNotes = state.notes; tagActive = state.activeNote; tagFolder = folder + const hasTags = state.notes.some(note => note.folder !== 'trash' && noteTagsForCount(note, state.activeNote, folder).length > 0) + const vaultRoot = state.vault?.root ?? null + if (!tagPresence || tagPresence.vaultRoot !== vaultRoot || tagPresence.hasTags !== hasTags) + tagPresence = Object.freeze({ vaultRoot, hasTags }) + return tagPresence +} +export function subscribeTagPresence(listener: (next: TagPresenceSnapshot) => void): () => void { + let previous = getTagPresenceSnapshot() + return useStore.subscribe(() => { + const next = getTagPresenceSnapshot() + if (next === previous) return + previous = next; listener(next) + }) +} +export function setNoteSortOrder(order: NoteSortOrder): void { useStore.getState().setNoteSortOrder(order) } diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 7cf9d9f3..6a2b6dc9 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -1698,15 +1698,17 @@ describe('deleteDatabaseRows (#391 — purge record-page schema mappings)', () = } it('purges the deleted row page mapping and trashes the note on confirm', async () => { - const moveToTrash = vi.fn().mockResolvedValue({}) + const moveToTrash = vi.fn().mockResolvedValue({ ...makeNote('', 'trash/r1.md'), folder: 'trash' }) installZen({ moveToTrash, + writeNote: vi.fn().mockImplementation(async (path) => makeNote('', path)), + setVaultSettings: vi.fn().mockImplementation(async (settings) => settings), writeDatabaseSchema: vi.fn().mockResolvedValue(undefined), writeDatabaseRows: vi.fn().mockResolvedValue(undefined) }) const { useStore } = await loadStore() const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') - useStore.setState({ databases: { [CSV]: makeDbDoc() } }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, databases: { [CSV]: makeDbDoc() } }) const p = useStore.getState().deleteDatabaseRows(CSV, ['r1']) const req = getConfirmRequest() @@ -1722,15 +1724,17 @@ describe('deleteDatabaseRows (#391 — purge record-page schema mappings)', () = }) it('keeps the note on cancel but still purges the stale mapping', async () => { - const moveToTrash = vi.fn().mockResolvedValue({}) + const moveToTrash = vi.fn().mockResolvedValue({ ...makeNote('', 'trash/r1.md'), folder: 'trash' }) installZen({ moveToTrash, + writeNote: vi.fn().mockImplementation(async (path) => makeNote('', path)), + setVaultSettings: vi.fn().mockImplementation(async (settings) => settings), writeDatabaseSchema: vi.fn().mockResolvedValue(undefined), writeDatabaseRows: vi.fn().mockResolvedValue(undefined) }) const { useStore } = await loadStore() const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') - useStore.setState({ databases: { [CSV]: makeDbDoc() } }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, databases: { [CSV]: makeDbDoc() } }) const p = useStore.getState().deleteDatabaseRows(CSV, ['r1']) settleConfirmRequest(getConfirmRequest()!, false) // "Keep note" @@ -1749,7 +1753,7 @@ describe('deleteDatabaseRows (#391 — purge record-page schema mappings)', () = }) const { useStore } = await loadStore() const { getConfirmRequest } = await import('./lib/confirm-requests') - useStore.setState({ databases: { [CSV]: makeDbDoc() } }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, databases: { [CSV]: makeDbDoc() } }) await useStore.getState().deleteDatabaseRows(CSV, ['r2']) // r2 has no linked page expect(getConfirmRequest()).toBeNull() // no prompt @@ -2193,7 +2197,7 @@ describe('deleteActivePermanently (#712)', () => { const { useStore } = await loadStore() const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') const note = trashedNote() - useStore.setState({ + useStore.setState({ vault: { root: '/test', name: 'Test' }, notes: [note], selectedPath: TRASHED, activeNote: note, @@ -2219,7 +2223,7 @@ describe('deleteActivePermanently (#712)', () => { const { useStore } = await loadStore() const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') const note = trashedNote() - useStore.setState({ notes: [note], selectedPath: TRASHED, activeNote: note, noteContents: { [TRASHED]: note } }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, notes: [note], selectedPath: TRASHED, activeNote: note, noteContents: { [TRASHED]: note } }) const p = useStore.getState().deleteActivePermanently() settleConfirmRequest(getConfirmRequest()!, false) @@ -2237,7 +2241,7 @@ describe('deleteActivePermanently (#712)', () => { const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') const { useToastStore } = await import('./lib/toast') const note = trashedNote() - useStore.setState({ notes: [note], selectedPath: TRASHED, activeNote: note, noteContents: { [TRASHED]: note } }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, notes: [note], selectedPath: TRASHED, activeNote: note, noteContents: { [TRASHED]: note } }) const p = useStore.getState().deleteActivePermanently() settleConfirmRequest(getConfirmRequest()!, true) @@ -2252,7 +2256,7 @@ describe('deleteActivePermanently (#712)', () => { const deleteNote = vi.fn().mockResolvedValue(undefined) installZen({ deleteNote }) const { useStore } = await loadStore() - useStore.setState({ selectedPath: null, activeNote: null }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, selectedPath: null, activeNote: null }) await useStore.getState().deleteActivePermanently() expect(deleteNote).not.toHaveBeenCalled() }) @@ -2451,3 +2455,27 @@ describe('ignored keys (#732)', () => { expect(useStore.getState().ignoredKeys).toEqual([]) }) }) + + +describe('file-task lifecycle coordination', () => { + it('trashes a file task outside the inline-task queue and keeps it on failure', async () => { + const source = makeNote('---\ntags: [task]\n---\nDraft.\n') + const moveToTrash=vi.fn().mockRejectedValueOnce(new Error('permission denied')).mockResolvedValue({...source,path:'trash/Note.md',folder:'trash'}) + installZen({moveToTrash,listNotes:vi.fn().mockResolvedValue([{...source,path:'trash/Note.md',folder:'trash'}])}) + const {useStore}=await loadStore() + const {getConfirmRequest,settleConfirmRequest}=await import('./lib/confirm-requests') + const task:VaultTask={...makeTask('Note',-1),id:'inbox/Note.md#file',taskIndex:-1,kind:'file',rawText:''} + useStore.setState({vault:{root:'/test',name:'Test'},notes:[source],noteContents:{[source.path]:source},vaultTasks:[task]}) + const failed=useStore.getState().deleteTaskFromList(task) + settleConfirmRequest(getConfirmRequest()!,true) + await failed + expect(moveToTrash).toHaveBeenCalledTimes(1) + expect(useStore.getState().vaultTasks).toEqual([task]) + expect(useStore.getState().noteContents[source.path]).toBeDefined() + const deleting=useStore.getState().deleteTaskFromList(task) + settleConfirmRequest(getConfirmRequest()!,true) + await deleting + expect(moveToTrash).toHaveBeenCalledTimes(2) + expect(useStore.getState().noteContents[source.path]).toBeUndefined() + }) +}) diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index a1df05d7..0909267d 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -1,4 +1,10 @@ +import type { LocalVaultRelocation } from './lib/workspace-relocation' +import { captureNavigationContext } from './lib/navigation-context' +import { runWorkspaceTransition, workspaceGeneration, workspaceWritesBlocked, isWorkspaceTransitionPending } from './lib/workspace-transition' +import { isNoteEditingLocked, lockNoteEditing } from './lib/note-lifecycle-lock' import { create } from 'zustand' +import { rewriteWikilinksForRename } from '@shared/wikilink-rename' +import { useToastStore } from './lib/toast' import type { EditorView } from '@codemirror/view' import { editorCursorPosition, @@ -13,7 +19,6 @@ import { type HarperLintConfig, type HarperVaultState } from '@shared/harper-settings' -import { resolveFolderPath } from '@shared/system-folder-paths' import { normalizeTasksExcludedFolder } from '@shared/tasks-excluded-folders' import { cloudSyncPathKey } from '@zennotes/shared-domain/cloud-sync' import { useCloudSyncStatusStore } from './lib/cloud-auto-sync' @@ -50,7 +55,6 @@ import { TYPST_PREAMBLE_FOLDER, isTypstPreamblePath, preambleKeyFromTitle, - resolveTypstPreamble, resolveTypstPreambleFolder, type TypstPreambleNote } from './lib/typst-preamble' @@ -66,7 +70,10 @@ import { import type { DatabaseDoc, DatabaseSidecar } from '@shared/databases' import { databaseTabPath, + csvPathFromDatabaseTab, formTitleFromCsvPath, + formDirFromCsvPath, + formDirContaining, isDatabaseInternalPath, isDatabaseTabPath, isDatabaseCsvPath @@ -80,7 +87,7 @@ import { ATLAS_TAB_PATH, isAtlasTabPath } from '@shared/atlas-view' import { HELP_TAB_PATH, isHelpTabPath } from '@shared/help' import { ARCHIVE_TAB_PATH, isArchiveTabPath } from '@shared/archive' import { TRASH_TAB_PATH, isTrashTabPath } from '@shared/trash' -import { ASSETS_VIEW_TAB_PATH, isAssetsViewTabPath } from '@shared/assets-view' +import { ASSETS_VIEW_TAB_PATH } from '@shared/assets-view' import { QUICK_NOTES_TAB_PATH, isQuickNotesTabPath } from '@shared/quick-notes' import { isAssetTabPath, assetPathFromTab, assetTabPath } from './lib/asset-tabs' import { @@ -116,10 +123,9 @@ import { customCodeLanguageRegistry } from './lib/custom-code-languages' import { formatMarkdown } from './lib/format-markdown' import { confirmDeletePermanently, confirmMoveToTrash } from './lib/confirm-trash' import { humanIpcError } from './lib/ipc-error' -import { deleteNotePermanently, moveNoteToTrash } from './lib/trash-note' -import { confirmApp } from './lib/confirm-requests' +import { confirmApp, getConfirmRequest } from './lib/confirm-requests' import { pickServerDirectoryApp } from './lib/server-directory-picker-requests' -import { promptApp } from './lib/prompt-requests' +import { promptApp, getPromptRequest } from './lib/prompt-requests' import { buildNoteDestinationPrompt, buildTemplateDestinationPrompt, @@ -164,6 +170,7 @@ import { removeFolderIcons, normalizeVaultSettings, noteFolderSubpath, + vaultRelativeFolderPath, resolveCreateLocation, rewriteFavoriteNotePath, rewriteFavoritesForFolderRename, @@ -205,7 +212,6 @@ import { leafWithoutTab, makeLeaf, mapLeaves, - replaceLeaf, rewritePathsInTree, preserveLayoutIfPruneEmptiesNoteTabs, splitLeaf, @@ -239,15 +245,8 @@ import { normalizeApplicationSchemes } from '@shared/application-links' import { normalizeEditorTabSize } from './lib/editor-tab-size' import { recentNoteToggleTarget } from './lib/recent-note-toggle' -export type NoteSortOrder = - | 'none' - | 'manual' - | 'updated-desc' - | 'updated-asc' - | 'created-desc' - | 'created-asc' - | 'name-asc' - | 'name-desc' +import type { NoteSortOrder } from './lib/note-order' +export type { NoteSortOrder } from './lib/note-order' /** Which column the Assets view sorts by, and in which direction. Stored as one * `-` string so it maps onto a single portable pref, the same @@ -2159,21 +2158,6 @@ function noteHistoryAfterJump( } } -function rewriteNoteJumpHistory( - history: NoteJumpLocation[], - rewrite: (path: string) => string -): NoteJumpLocation[] { - const next: NoteJumpLocation[] = [] - for (const entry of history) { - const mapped = { ...entry, path: rewrite(entry.path) } - if (sameNoteJumpLocation(next[next.length - 1] ?? null, mapped)) continue - next.push(mapped) - } - return next.length > MAX_NOTE_JUMP_HISTORY - ? next.slice(next.length - MAX_NOTE_JUMP_HISTORY) - : next -} - /** * Rewrite every occurrence of `#oldTag` across all non-trash notes. * When `newTag` is null the hashtag is stripped (delete semantics); @@ -2737,15 +2721,13 @@ function hasTasksViewOpen(state: { paneLayout: PaneLayout }): boolean { } /** True when a surface backed by `vaultTasks` is on screen and therefore needs - * the shared task cache kept fresh on note edits. Covers the Tasks view and the - * calendar panel — the latter is per-pane local state exposed via a DOM marker - * (the same one VimNav reads for pane navigation), so editing a daily note with - * only the calendar open still refreshes its tasks. */ + * the shared task cache kept fresh on note edits. Tasks tabs are in the pane + * tree; Home and calendar expose the navigation markers also read by VimNav. */ function tasksSurfaceVisible(state: { paneLayout: PaneLayout }): boolean { if (hasTasksViewOpen(state)) return true return ( typeof document !== 'undefined' && - document.querySelector('[data-calendar-panel]') !== null + document.querySelector('[data-calendar-panel], [data-home-nav]') !== null ) } @@ -2861,6 +2843,7 @@ interface Store { query: string initialized: boolean workspaceRestored: boolean + workspaceTransitioning: boolean sidebarOpen: boolean noteListOpen: boolean zenMode: boolean @@ -3093,6 +3076,7 @@ interface Store { /** Hydrated CSV databases keyed by their vault-relative `.csv` path. */ databases: Record /** In-flight load flags keyed by `.csv` path. */ + databasesDeletingRows: Record databasesLoading: Record /** Tags currently selected in the Tags view. The view shows every non- @@ -3202,11 +3186,11 @@ interface Store { /** Load a database and open it as a tab in the active pane. */ openDatabase: (csvPath: string) => Promise /** Create a new empty database under `folder`/`subpath` and open it. */ - createDatabase: (folder: NoteFolder, subpath?: string, title?: string) => Promise + createDatabase: (folder: NoteFolder, subpath?: string, title?: string, isCurrent?: () => boolean) => Promise /** Create a database in the configured default databases location and open it. (#362) */ newDatabase: () => Promise /** Rename a database (its `.base` folder); rehomes the open grid tab. */ - renameDatabase: (csvPath: string, newTitle: string) => Promise + renameDatabase: (csvPath: string, newTitle: string, isCurrent?: () => boolean) => Promise /** Optimistically replace a database's rows and debounce-persist the CSV. */ updateDatabaseRows: (csvPath: string, next: DatabaseDoc) => void /** Delete rows AND purge their record-page mappings from the sidecar (a plain @@ -3331,7 +3315,7 @@ interface Store { updateActiveBody: (body: string) => void persistActive: () => Promise formatActiveNote: () => Promise - renameNote: (oldPath: string, nextTitle: string) => Promise + renameNote: (oldPath: string, nextTitle: string, hostIsCurrent?: () => boolean) => Promise renameActive: (nextTitle: string) => Promise createAndOpen: ( folder: NoteFolder, @@ -3377,6 +3361,8 @@ interface Store { /** Delete any note for good (confirm, delete, drop its tabs and buffers). * Resolves true when the file is gone. */ deleteNotePermanently: (path: string) => Promise + emptyTrash: (hostIsCurrent?: () => boolean) => Promise + changeNoteLifecycle: (path: string, action: 'archive' | 'trash' | 'restore' | 'delete', hostIsCurrent?: () => boolean) => Promise restoreActive: () => Promise archiveActive: () => Promise unarchiveActive: () => Promise @@ -3652,7 +3638,7 @@ interface Store { /** Update an open note's body (typed into any pane). Flags dirty. */ updateNoteBody: (path: string, body: string) => void /** Persist a specific note to disk. */ - persistNote: (path: string) => Promise + persistNote: (path: string, duringFolderMutation?: boolean) => Promise loadNoteComments: (path: string) => Promise addNoteComment: (input: NoteCommentInput) => Promise updateNoteComment: ( @@ -3673,13 +3659,14 @@ interface Store { renameTag: (oldTag: string, newTag: string) => Promise /** Remove `#tag` from every non-trash note. */ deleteTag: (tag: string) => Promise - createFolder: (folder: NoteFolder, subpath: string) => Promise + createFolder: (folder: NoteFolder, subpath: string, isCurrent?: () => boolean) => Promise renameFolder: ( folder: NoteFolder, oldSubpath: string, - newSubpath: string + newSubpath: string, + isCurrent?: () => boolean ) => Promise - deleteFolder: (folder: NoteFolder, subpath: string) => Promise + deleteFolder: (folder: NoteFolder, subpath: string, isCurrent?: () => boolean) => Promise duplicateFolder: (folder: NoteFolder, subpath: string) => Promise revealFolder: (folder: NoteFolder, subpath: string) => Promise revealAssetsDir: () => Promise @@ -3687,11 +3674,13 @@ interface Store { moveNote: ( relPath: string, targetFolder: NoteFolder, - targetSubpath: string + targetSubpath: string, + isCurrent?: () => boolean ) => Promise init: () => Promise openVaultPicker: () => Promise openLocalVault: (root: string) => Promise + relocateLocalVault: (operation: LocalVaultRelocation) => Promise closeVault: () => Promise connectRemoteWorkspace: () => Promise connectRemoteWorkspaceProfile: (id: string) => Promise @@ -3753,6 +3742,138 @@ function databaseToSidecar(doc: DatabaseDoc): DatabaseSidecar { } } +const databaseLoadVersions = new Map() +const databaseWriteQueues = new Map>() +const databaseCreations = new Map>() +const databaseRowActions = new Map>() +let pendingRowConfirmation = false +const folderMutations = new Map>() +const uncertainFolderMutations = new Map() +let noteIndexRequest = 0 +let assetIndexRequest = 0 +let taskIndexRevision = 0 +const inFlightNoteWrites = new Set>() +let pendingNoteRename: { + oldPath: string + nextPath: string + title: string + notesBefore: NoteMeta[] + isCurrent: () => boolean +} | null = null + +function rewriteRenamingBody(path: string, body: string, folder: NoteFolder): string { + const rename = pendingNoteRename + if ( + !rename || + !rename.isCurrent() || + path === rename.nextPath || + folder === 'trash' || + !path.toLowerCase().endsWith('.md') || + isObsidianExcalidrawPath(path) || + isObsidianExcalidrawMarkdown(body) + ) + return body + return rewriteWikilinksForRename(body, rename.notesBefore, rename.oldPath, rename.title).body +} + +/** Body writers outside the editor must settle before a file mutation starts. */ +function trackNoteWrite( + blocked: Result, + work: (...args: Args) => Promise +): (...args: Args) => Promise { + return async (...args) => { + if ( + workspaceWritesBlocked() || folderMutations.size > 0 || databaseRowActions.size > 0 || + [...uncertainFolderMutations.values()].includes(useStore.getState().vault) + ) { + useToastStore + .getState() + .addToast( + 'Wait for the file operation to finish, or reload the vault if it failed.', + 'info' + ) + return blocked + } + const running = work(...args) + inFlightNoteWrites.add(running) + try { + return await running + } finally { + inFlightNoteWrites.delete(running) + } + } +} + +const folderReadVersions = new Map() +const mutationContains = (scope: string, path: string): boolean => + scope === '' || scope.endsWith('/') ? path.startsWith(scope) : path === scope +const folderReadVersion = (path: string): number => + [...folderReadVersions].reduce( + (version, [prefix, value]) => (mutationContains(prefix, path) ? version + value : version), + 0 + ) +const folderMutationBlocks = (path: string): boolean => + [...folderMutations.keys()].some((prefix) => mutationContains(prefix, path)) || + [...uncertainFolderMutations].some( + ([prefix, vault]) => vault === useStore.getState().vault && mutationContains(prefix, path) + ) + +/** Register every task writer, including actions that write a closed note directly. */ +function trackTaskWrite( + work: (...args: Args) => Promise +): (...args: Args) => Promise { + return async (...args) => { + if ([...uncertainFolderMutations.values()].includes(useStore.getState().vault)) { + useToastStore.getState().addToast('Reload the vault before changing tasks after a failed file operation.', 'error') + return + } + if (workspaceWritesBlocked() || folderMutations.size > 0 || databaseRowActions.size > 0) { + useToastStore.getState().addToast('Wait for the file operation to finish before changing tasks.', 'info') + return + } + const running = work(...args) + inFlightTaskMutations.add(running) + try { await running } finally { inFlightTaskMutations.delete(running) } + } +} + +async function flushDatabaseWrite( + csvPath: string, + getDoc: () => DatabaseDoc | undefined +): Promise { + const isCurrent = captureFolderActionContext(useStore.getState) + const timer = databaseSaveTimers.get(csvPath) + if (timer) clearTimeout(timer) + databaseSaveTimers.delete(csvPath) + const previous = databaseWriteQueues.get(csvPath) + const write = async (): Promise => { + if (!isCurrent()) return + const kind = databaseWriteKind.get(csvPath) + const doc = getDoc() + if (!kind || !doc) return + databaseWriteKind.delete(csvPath) + try { + if (kind === 'schema') + await window.zen.writeDatabaseSchema(csvPath, databaseToSidecar(doc), doc.rows) + else await window.zen.writeDatabaseRows(csvPath, doc.rows) + lastDatabaseWriteAt.set(csvPath, Date.now()) + } catch (error) { + databaseWriteKind.set( + csvPath, + kind === 'schema' ? kind : (databaseWriteKind.get(csvPath) ?? kind) + ) + throw error + } + } + const run = previous ? previous.catch(() => {}).then(write) : write() + databaseWriteQueues.set(csvPath, run) + try { + await run + } finally { + if (databaseWriteQueues.get(csvPath) === run) databaseWriteQueues.delete(csvPath) + } +} + function scheduleDatabaseWrite( csvPath: string, kind: 'rows' | 'schema', @@ -3762,24 +3883,11 @@ function scheduleDatabaseWrite( databaseWriteKind.set(csvPath, kind === 'schema' || prev === 'schema' ? 'schema' : 'rows') const existing = databaseSaveTimers.get(csvPath) if (existing) clearTimeout(existing) - databaseSaveTimers.set( - csvPath, - setTimeout(() => { - databaseSaveTimers.delete(csvPath) - const writeKind = databaseWriteKind.get(csvPath) ?? 'rows' - databaseWriteKind.delete(csvPath) - const doc = getDoc() - if (!doc) return - const done = (): void => { - lastDatabaseWriteAt.set(csvPath, Date.now()) - } - const write = - writeKind === 'schema' - ? window.zen.writeDatabaseSchema(csvPath, databaseToSidecar(doc), doc.rows) - : window.zen.writeDatabaseRows(csvPath, doc.rows) - void write.catch((err) => console.error('database write failed', err)).finally(done) - }, DATABASE_SAVE_DEBOUNCE_MS) - ) + databaseSaveTimers.delete(csvPath) + if (folderMutationBlocks(csvPath) || databaseRowActions.has(csvPath)) return + databaseSaveTimers.set(csvPath, setTimeout(() => { + void flushDatabaseWrite(csvPath, getDoc).catch((err) => console.error('database write failed', err)) + }, DATABASE_SAVE_DEBOUNCE_MS)) } /** @@ -3917,43 +4025,282 @@ function activeFieldsFrom( } } -function renameNoteState( +const commentOperations = new Map>>() + +async function trackCommentOperation( + path: string, + fallback: T, + work: () => Promise +): Promise { + if (folderMutationBlocks(path) || workspaceWritesBlocked()) return fallback + const operations = commentOperations.get(path) ?? new Set>() + commentOperations.set(path, operations) + const pending = work() + operations.add(pending) + try { + return await pending + } finally { + operations.delete(pending) + if (operations.size === 0) commentOperations.delete(path) + } +} + +function captureFolderActionContext( + get: () => Store, + hostIsCurrent?: () => boolean +): () => boolean { + const vault = get().vault + const bridge = window.zen + const layout = JSON.stringify([ + get().vaultSettings.primaryNotesLocation, + get().vaultSettings.systemFolderPaths + ]) + return () => { + try { + return ( + get().vault === vault && + window.zen === bridge && + JSON.stringify([ + get().vaultSettings.primaryNotesLocation, + get().vaultSettings.systemFolderPaths + ]) === layout && + (hostIsCurrent?.() ?? true) + ) + } catch { + return false + } + } +} + +/** Keep saves and watcher echoes at the old paths until the host finishes moving them. */ +async function mutateFolderContents( + get: () => Store, + prefix: string, + isCurrent: () => boolean, + canReconcile: () => boolean, + mutate: () => Promise, + rowActionOwner?: string +): Promise { + if (workspaceWritesBlocked()) throw new Error('Wait for the vault change to finish') + if ([...databaseRowActions.keys()].some((owner) => owner !== rowActionOwner)) + throw new Error('Wait for database row deletion to finish before changing files') + if (inFlightNoteWrites.size > 0) + throw new Error('Wait for pending note changes to finish before changing files') + if ( + [...databaseCreations.keys()].some((other) => prefix.startsWith(other) || other.startsWith(prefix)) || + folderMutationBlocks(prefix) || + [...uncertainFolderMutations].some(([other, vault]) => vault === get().vault && other.startsWith(prefix)) || + [...folderMutations.keys()].some( + (other) => prefix.startsWith(other) || other.startsWith(prefix) + ) + ) + throw new Error('This folder already has an operation in progress') + let release!: () => void + const done = new Promise((resolve) => { + release = resolve + }) + folderMutations.set(prefix, done) + folderReadVersions.set(prefix, (folderReadVersions.get(prefix) ?? 0) + 1) + taskIndexRevision += 1 + useStore.setState({ tasksLoading: false }) + noteIndexRequest += 1 + assetIndexRequest += 1 + const notePaths = Object.keys(get().noteContents).filter((path) => mutationContains(prefix, path)) + const databasePaths = [ + ...new Set([ + ...Object.keys(get().databases), + ...Object.keys(get().databasesLoading), + ...databaseWriteQueues.keys(), + ...databaseWriteKind.keys() + ]) + ].filter((path) => mutationContains(prefix, path)) + for (const path of databasePaths) + databaseLoadVersions.set(path, (databaseLoadVersions.get(path) ?? 0) + 1) + useStore.setState((s) => ({ + databasesLoading: { + ...s.databasesLoading, + ...Object.fromEntries(databasePaths.map((path) => [path, false])) + } + })) + let nextPrefix: string | null = prefix + for (const path of notePaths) { + renamesInFlight.add(path) + noteContentVersions.set(path, (noteContentVersions.get(path) ?? 0) + 1) + } + try { + await Promise.all( + [...commentOperations] + .filter(([path]) => mutationContains(prefix, path)) + .flatMap(([, operations]) => [...operations]) + ) + if (!isCurrent()) return + await Promise.all(notePaths.map((path) => get().persistNote(path, true))) + if (!isCurrent()) return + if (notePaths.some((path) => get().noteDirty[path])) + throw new Error('Could not change this folder while notes still have unsaved changes') + await Promise.all( + databasePaths.map((path) => + flushDatabaseWrite(path, () => (isCurrent() ? get().databases[path] : undefined)) + ) + ) + if (!isCurrent()) return + nextPrefix = (await mutate()) ?? null + } catch (error) { + if (String(error).includes('FOLDER_STATE_UNCERTAIN:') && canReconcile()) { + uncertainFolderMutations.set(prefix, get().vault) + nextPrefix = null + } + throw error + } finally { + for (const path of notePaths) renamesInFlight.delete(path) + for (const path of databasePaths) { + const kind = databaseWriteKind.get(path) + databaseWriteKind.delete(path) + const timer = databaseSaveTimers.get(path) + if (timer) clearTimeout(timer) + databaseSaveTimers.delete(path) + if (canReconcile() && nextPrefix !== null && kind) { + const nextPath = nextPrefix + path.slice(prefix.length) + databaseWriteKind.set( + nextPath, + kind === 'schema' ? kind : (databaseWriteKind.get(nextPath) ?? kind) + ) + } + } + try { + if (canReconcile() && nextPrefix !== null) { + const targetPrefix = nextPrefix + await Promise.all( + Object.keys(get().noteDirty) + .filter((path) => mutationContains(targetPrefix, path) && get().noteDirty[path]) + .map((path) => get().persistNote(path, true)) + ) + } + } finally { + folderMutations.delete(prefix) + if (canReconcile() && nextPrefix !== null) { + const targetPrefix = nextPrefix + for (const path of Object.keys(get().databases)) { + const kind = databaseWriteKind.get(path) + if (mutationContains(targetPrefix, path) && kind) + scheduleDatabaseWrite(path, kind, () => get().databases[path]) + } + } + release() + } + } +} + +function rewriteFolderWorkspace( s: Store, - oldPath: string, - meta: NoteMeta + prefix: string, + nextPrefix: string | null ): Partial { - const rewrite = (p: string): string => (p === oldPath ? meta.path : p) - const nextLayout = rewritePathsInTree(s.paneLayout, rewrite) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents = { ...s.noteContents } - const dirty = { ...s.noteDirty } - const prevContent = contents[oldPath] - const prevDirty = dirty[oldPath] ?? false - if (oldPath !== meta.path) { - delete contents[oldPath] - delete dirty[oldPath] + const rewriteFile = (path: string): string | null => + mutationContains(prefix, path) + ? nextPrefix === null + ? null + : nextPrefix + path.slice(prefix.length) + : path + const rewrite = (path: string): string | null => { + const csv = csvPathFromDatabaseTab(path) + const asset = isAssetTabPath(path) ? assetPathFromTab(path) : null + const mapped = rewriteFile(csv ?? asset ?? path) + return mapped === null + ? null + : csv + ? databaseTabPath(mapped) + : asset + ? assetTabPath(mapped) + : mapped } - if (prevContent) { - contents[meta.path] = { ...prevContent, ...meta } + const remap = ( + entries: Record, + update: (value: T, path: string) => T + ): Record => { + const next: Record = {} + for (const [path, value] of Object.entries(entries)) { + const mapped = rewriteFile(path) + if (mapped !== null) next[mapped] = mapped === path ? value : update(value, mapped) + } + return next } - dirty[meta.path] = prevDirty + const contents = remap(s.noteContents, (content, path) => ({ ...content, path })) + const dirty = remap(s.noteDirty, (value) => value) + const ensured = ensureActivePane(rewritePathsInTree(s.paneLayout, rewrite), s.activePaneId) + const history = (entries: NoteJumpLocation[]) => + entries.flatMap((entry) => { + const path = rewrite(entry.path) + return path === null ? [] : [{ ...entry, path }] + }) + const pendingPath = s.pendingJumpLocation && rewrite(s.pendingJumpLocation.path) return { paneLayout: ensured.layout, activePaneId: ensured.activePaneId, noteContents: contents, noteDirty: dirty, - notes: replaceNoteMeta(s.notes, oldPath, meta), - noteBackstack: rewriteNoteJumpHistory(s.noteBackstack, rewrite), - noteForwardstack: rewriteNoteJumpHistory(s.noteForwardstack, rewrite), - pendingJumpLocation: - s.pendingJumpLocation?.path === oldPath - ? { ...s.pendingJumpLocation, path: meta.path } - : s.pendingJumpLocation, - pendingTitleFocusPath: - s.pendingTitleFocusPath === oldPath ? meta.path : s.pendingTitleFocusPath, - pinnedRefPath: s.pinnedRefPath === oldPath ? meta.path : s.pinnedRefPath, - noteComments: rewriteNoteCommentsPath(s.noteComments, oldPath, meta.path), - activeCommentId: s.activeCommentId, + manualNoteOrder: Object.fromEntries( + Object.entries(s.manualNoteOrder).flatMap(([directory, paths]) => { + const mapped = rewriteFile(`${directory}/`) + return mapped + ? [ + [ + mapped.slice(0, -1), + paths.flatMap((path) => { + const next = rewriteFile(path) + return next ? [next] : [] + }) + ] + ] + : [] + }) + ), + paneModes: Object.fromEntries( + Object.entries(s.paneModes).map(([pane, modes]) => [pane, remap(modes, (mode) => mode)]) + ), + noteRefs: Object.fromEntries( + Object.entries(s.noteRefs).flatMap(([owner, ref]) => { + const nextOwner = rewriteFile(owner) + const path = rewriteFile(ref.path) + return nextOwner && path ? [[nextOwner, { ...ref, path }]] : [] + }) + ), + assetFiles: s.assetFiles.flatMap((asset) => { + const path = rewriteFile(asset.path) + return path ? [{ ...asset, path }] : [] + }), + vaultTasks: s.vaultTasks.flatMap((task) => { + const sourcePath = rewriteFile(task.sourcePath) + return sourcePath + ? [{ ...task, sourcePath, id: sourcePath + task.id.slice(task.sourcePath.length) }] + : [] + }), + tasksLoading: false, + noteComments: remap(s.noteComments, (comments, notePath) => + comments.map((comment) => ({ ...comment, notePath })) + ), + databases: remap(s.databases, (doc, path) => ({ + ...doc, + path, + title: formTitleFromCsvPath(path), + ...(doc.pages + ? { + pages: Object.fromEntries( + Object.entries(doc.pages).map(([id, page]) => [id, rewriteFile(page) ?? page]) + ) + } + : {}) + })), + databasesLoading: remap(s.databasesLoading, () => false), + noteBackstack: history(s.noteBackstack), + noteForwardstack: history(s.noteForwardstack), + closedTabStack: s.closedTabStack.flatMap((entry) => { + const path = rewrite(entry.path) + return path === null ? [] : [{ ...entry, path }] + }), + pendingJumpLocation: pendingPath ? { ...s.pendingJumpLocation!, path: pendingPath } : null, + pendingTitleFocusPath: s.pendingTitleFocusPath ? rewrite(s.pendingTitleFocusPath) : null, + pinnedRefPath: s.pinnedRefPath ? rewrite(s.pinnedRefPath) : null, ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) } } @@ -3974,10 +4321,12 @@ async function syncHeadingAfterRename( syncTitleHeadingOnRename: boolean noteContents: Record updateNoteBody: (path: string, body: string) => void - persistNote: (path: string) => Promise - } + persistNote: (path: string, duringFolderMutation?: boolean) => Promise + }, + isCurrent: () => boolean = () => true, + duringMutation = false ): Promise { - if (!get().syncTitleHeadingOnRename) return + if (!isCurrent() || !get().syncTitleHeadingOnRename) return // Markdown only, and never an Obsidian drawing: those are `.md` files whose // headings (`# Excalidraw Data`) are structure, not a title. if (!meta.path.toLowerCase().endsWith('.md')) return @@ -3989,14 +4338,15 @@ async function syncHeadingAfterRename( const next = retitleLeadingHeading(open.body, meta.title) if (next === open.body) return get().updateNoteBody(meta.path, next) - await get().persistNote(meta.path) + await get().persistNote(meta.path, duringMutation) return } - const content = await window.zen.readNote(meta.path) - if (isObsidianExcalidrawMarkdown(content.body)) return + const bridge = window.zen + const content = await bridge.readNote(meta.path) + if (!isCurrent() || isObsidianExcalidrawMarkdown(content.body)) return const next = retitleLeadingHeading(content.body, meta.title) if (next === content.body) return - await window.zen.writeNote(meta.path, next) + await bridge.writeNote(meta.path, next) } catch (err) { // The rename itself succeeded; a failed heading rewrite must not undo it. console.error('syncHeadingAfterRename failed', err) @@ -4107,19 +4457,6 @@ function withDateNotePatternHistory( } } -function rewriteNoteCommentsPath( - comments: Record, - oldPath: string, - nextPath: string -): Record { - if (oldPath === nextPath || !(oldPath in comments)) return comments - const { [oldPath]: moving, ...rest } = comments - return { - ...rest, - [nextPath]: moving.map((comment) => ({ ...comment, notePath: nextPath })) - } -} - /** Ensure `activePaneId` points at a real leaf. Falls back to first leaf. */ function ensureActivePane( layout: PaneLayout, @@ -4145,11 +4482,13 @@ function noteReadCacheKey( relPath: string ): string { return [ + workspaceGeneration(), state.workspaceMode, state.vault?.root ?? '', state.remoteWorkspaceInfo?.baseUrl ?? '', state.remoteWorkspaceInfo?.profileId ?? '', - relPath + relPath, + folderReadVersion(relPath) ].join('\0') } @@ -4159,11 +4498,17 @@ function clearNoteContentReadCaches(): void { } function readNoteContent(relPath: string, state: Store): Promise { + if (folderMutationBlocks(relPath)) return Promise.reject(new Error('Folder operation in progress')) + const version = folderReadVersion(relPath) const cacheKey = noteReadCacheKey(state, relPath) const pending = noteReadPromises.get(cacheKey) if (pending) return pending - const next = window.zen.readNote(relPath).finally(() => { + const next = window.zen.readNote(relPath).then((content) => { + if (folderMutationBlocks(relPath) || folderReadVersion(relPath) !== version) + throw new Error('Folder changed while loading this note') + return content + }).finally(() => { noteReadPromises.delete(cacheKey) }) noteReadPromises.set(cacheKey, next) @@ -4266,11 +4611,184 @@ function withoutNoteInWorkspace(s: Store, path: string): Partial { } export const useStore = create((set, get) => { + const mutateNoteImpl = async ( + path: string, + mutate: () => Promise, + hostIsCurrent?: () => boolean, + rename = false, + rowActionOwner?: string + ): Promise => { + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + const canReconcile = captureFolderActionContext(get) + if (!isCurrent()) return null + if (inFlightTaskMutations.size > 0 || taskMutationQueues.size > 0) + throw new Error('Wait for pending task changes to finish before changing this note') + let result: NoteMeta | null = null + // Rename can rewrite links anywhere in the vault, including buffers edited + // while the host is working. Hold those saves until their links are updated. + try { + await mutateFolderContents(get, rename ? '' : path, isCurrent, canReconcile, async () => { + const notesBefore = get().notes + result = await mutate() + if (!canReconcile()) return + if (rename && result && result.path !== path) + pendingNoteRename = { + oldPath: path, + nextPath: result.path, + title: result.title, + notesBefore, + isCurrent: canReconcile + } + const nextPath = result?.path ?? null + noteIndexRequest += 1 + taskIndexRevision += 1 + if (nextPath) folderReadVersions.set(nextPath, (folderReadVersions.get(nextPath) ?? 0) + 1) + set((s) => { + const rewritten = rewriteFolderWorkspace(s, path, nextPath) + const manualNoteOrder = { ...rewritten.manualNoteOrder } + const parent = parentDirOf(path) + const nextParent = nextPath ? parentDirOf(nextPath) : null + if (nextParent !== parent && s.manualNoteOrder[parent]?.includes(path)) { + manualNoteOrder[parent] = s.manualNoteOrder[parent].filter((value) => value !== path) + if (nextParent !== null) + manualNoteOrder[nextParent] = [ + ...(manualNoteOrder[nextParent] ?? []).filter((value) => value !== nextPath), + nextPath! + ] + } + const contents = rewritten.noteContents! + if (rename) { + for (const [owner, content] of Object.entries(contents)) { + const body = rewriteRenamingBody(owner, content.body, content.folder) + if (body !== content.body) { + contents[owner] = { ...content, body } + rewritten.noteDirty![owner] = true + } + } + } + if (result && contents[result.path]) + contents[result.path] = { ...contents[result.path], ...result } + return { + ...rewritten, + manualNoteOrder, + notes: result + ? replaceNoteMeta(s.notes, path, result) + : s.notes.filter((note) => note.path !== path), + vaultTasks: rewritten.vaultTasks!.map((task) => + result && task.sourcePath === result.path + ? { ...task, noteFolder: result.folder, noteTitle: result.title } + : task + ), + ...activeFieldsFrom( + rewritten.paneLayout!, + rewritten.activePaneId!, + contents, + rewritten.noteDirty! + ) + } + }) + savePrefs(collectPrefs(get())) + writeManualOrder(get().vault?.root ?? '', get().manualNoteOrder) + await get().applyFavorites( + nextPath && result?.folder !== 'trash' + ? rewriteFavoriteNotePath(get().vaultSettings.favorites, path, nextPath) + : get().vaultSettings.favorites.filter((favorite) => favorite !== path) + ) + if (rename && result) await syncHeadingAfterRename(result, get, canReconcile, true) + if (isCurrent()) await get().refreshNotes() + return rename ? '' : nextPath + }, rowActionOwner) + } finally { + if (rename && pendingNoteRename?.isCurrent === canReconcile) pendingNoteRename = null + } + if (isCurrent() && tasksSurfaceVisible(get())) await get().refreshTasks() + if (rename && isCurrent() && Object.values(get().noteDirty).some(Boolean)) + throw new Error( + 'The rename finished, but notes still have unsaved changes. Retry saving before leaving the vault.' + ) + const finalMeta = result as NoteMeta | null + if (!rename && canReconcile() && finalMeta && get().noteDirty[finalMeta.path]) + throw new Error('The note moved, but it still has unsaved changes. Save it before closing it.') + return result + } + + const renameFolderImpl = async ( + folder: NoteFolder, + oldSubpath: string, + oldPrefix: string, + rename: () => Promise<{ subpath: string; prefix: string }>, + hostIsCurrent?: () => boolean + ): Promise => { + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + const canReconcile = captureFolderActionContext(get) + if (!isCurrent()) return + await mutateFolderContents(get, oldPrefix, isCurrent, canReconcile, async () => { + const { subpath: newSubpath, prefix: newPrefix } = await rename() + if (!canReconcile()) return + noteIndexRequest += 1 + taskIndexRevision += 1 + assetIndexRequest += 1 + set((s) => ({ + ...rewriteFolderWorkspace(s, oldPrefix, newPrefix), + view: + s.view.kind === 'folder' && + s.view.folder === folder && + (s.view.subpath === oldSubpath || s.view.subpath.startsWith(`${oldSubpath}/`)) + ? { ...s.view, subpath: newSubpath + s.view.subpath.slice(oldSubpath.length) } + : s.view, + notes: s.notes.map((note) => + note.path.startsWith(oldPrefix) + ? { ...note, path: newPrefix + note.path.slice(oldPrefix.length) } + : note + ), + folders: s.folders.map((entry) => + entry.folder === folder && + (entry.subpath === oldSubpath || entry.subpath.startsWith(`${oldSubpath}/`)) + ? { ...entry, subpath: newSubpath + entry.subpath.slice(oldSubpath.length) } + : entry + ), + vaultSettings: { + ...s.vaultSettings, + folderIcons: rewriteFolderIconsForRename( + s.vaultSettings.folderIcons, + folder, + oldSubpath, + newSubpath + ), + folderColors: rewriteFolderColorsForRename( + s.vaultSettings.folderColors, + folder, + oldSubpath, + newSubpath + ) + } + })) + savePrefs(collectPrefs(get())) + writeManualOrder(get().vault?.root ?? '', get().manualNoteOrder) + await get().applyFavorites( + rewriteFavoritesForFolderRename( + get().vaultSettings.favorites, + folder, + oldSubpath, + newSubpath, + oldPrefix, + newPrefix + ) + ) + if (!isCurrent()) return newPrefix + await get().refreshNotes() + if (!isCurrent()) return newPrefix + return newPrefix + }) + } + const selectNoteImpl = async ( relPath: string | null, historyMode: 'push' | 'preserve' = 'push', opts?: { preview?: boolean } ): Promise => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false const startedAt = performance.now() const state = get() const activeLeaf = findLeaf(state.paneLayout, state.activePaneId) @@ -4317,6 +4835,7 @@ export const useStore = create((set, get) => { state.noteDirty[state.selectedPath] ) { await get().persistNote(state.selectedPath) + if (!isCurrent()) return false } const latest = get() const leafNow = findLeaf(latest.paneLayout, latest.activePaneId) @@ -4391,6 +4910,7 @@ export const useStore = create((set, get) => { state.noteDirty[state.selectedPath] ) { await get().persistNote(state.selectedPath) + if (!isCurrent()) return false } const latest = get() @@ -4404,6 +4924,7 @@ export const useStore = create((set, get) => { const readScopeKey = noteReadCacheKey(latest, relPath) const content = await readNoteContent(relPath, latest) const s = get() + if (!isCurrent()) return false if (noteReadCacheKey(s, relPath) !== readScopeKey) { set({ loadingNote: false }) return false @@ -4437,12 +4958,15 @@ export const useStore = create((set, get) => { path: relPath }) console.error('readNote failed', err) + if (!isCurrent()) return false set({ loadingNote: false, pendingJumpLocation: null }) return false } } const jumpThroughNoteHistory = async (direction: 'back' | 'forward'): Promise => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return const state = get() const source = direction === 'back' ? [...state.noteBackstack] : [...state.noteForwardstack] @@ -4450,6 +4974,7 @@ export const useStore = create((set, get) => { if (state.selectedPath && state.noteDirty[state.selectedPath]) { await get().persistNote(state.selectedPath) + if (!isCurrent()) return } set({ loadingNote: true }) @@ -4484,7 +5009,9 @@ export const useStore = create((set, get) => { return } try { + const scope = noteReadCacheKey(get(), target.path) const content = await readNoteContent(target.path, get()) + if (!isCurrent() || noteReadCacheKey(get(), target.path) !== scope) return const latest = get() const leaf = findLeaf(latest.paneLayout, latest.activePaneId) if (!leaf) continue @@ -4510,6 +5037,7 @@ export const useStore = create((set, get) => { return } catch (err) { console.error(`jump ${direction} readNote failed`, err) + if (!isCurrent()) return } } @@ -4689,99 +5217,334 @@ export const useStore = create((set, get) => { } } - return { - vault: null, - workspaceMode: 'local', - remoteWorkspaceInfo: null, - remoteWorkspaceProfiles: [], - localVaults: [], - workspaceSetupError: null, - vaultSettings: DEFAULT_VAULT_SETTINGS, - rootContentHiddenByInboxMode: false, - rootContentBannerDismissed: false, - manualNoteOrder: {}, - notes: [], - typstPreambleNotes: [], - folders: [], - assetFiles: [], - assetUndoStack: [], - hasAssetsDir: false, - view: { kind: 'folder', folder: 'inbox', subpath: '' }, - selectedPath: null, - activeNote: null, - activeDirty: false, - noteBackstack: [], - noteForwardstack: [], - pendingJumpLocation: null, - loadingNote: false, - searchOpen: false, - vaultTextSearchOpen: false, - commandPaletteOpen: false, - commandPaletteInitialMode: 'main', - bufferPaletteOpen: false, - outlinePaletteOpen: false, - templatePaletteOpen: false, - embedDrawingPaletteOpen: false, - excalidrawPreviewVersion: 0, - templatePaletteMode: 'create', - templatePaletteTarget: null, - customTemplates: [], - workflowIndex: [], - query: '', - initialized: false, - workspaceRestored: false, - sidebarOpen: true, - noteListOpen: true, - zenMode: false, - zenRestoreState: null, - vimMode: loadPrefs().vimMode, - vimInsertEscape: loadPrefs().vimInsertEscape, - ignoredKeys: loadPrefs().ignoredKeys, - externalApplicationSchemes: loadPrefs().externalApplicationSchemes, - vimYankToClipboard: loadPrefs().vimYankToClipboard, - vimBlockImeInNormalMode: loadPrefs().vimBlockImeInNormalMode, - vimWrappedLineMotions: loadPrefs().vimWrappedLineMotions, - keymapOverrides: loadPrefs().keymapOverrides, - enabledOverrides: loadPrefs().enabledOverrides, - themeTweaks: loadPrefs().themeTweaks, - whichKeyHints: loadPrefs().whichKeyHints, - whichKeyHintMode: loadPrefs().whichKeyHintMode, - whichKeyHintTimeoutMs: loadPrefs().whichKeyHintTimeoutMs, - vaultTextSearchBackend: loadPrefs().vaultTextSearchBackend, - ripgrepBinaryPath: loadPrefs().ripgrepBinaryPath, - fzfBinaryPath: loadPrefs().fzfBinaryPath, - livePreview: loadPrefs().livePreview, - showHeadingLevelLabels: loadPrefs().showHeadingLevelLabels, - listIndentGuides: loadPrefs().listIndentGuides, - renderTablesInLivePreview: loadPrefs().renderTablesInLivePreview, - completedTaskStyle: loadPrefs().completedTaskStyle, - mathRenderer: loadPrefs().mathRenderer, - typstTagPreambles: loadPrefs().typstTagPreambles, - harperEnabled: loadPrefs().harperEnabled, - harperDialect: loadPrefs().harperDialect, - harperLintConfig: loadPrefs().harperLintConfig, - looseMathDelimiters: loadPrefs().looseMathDelimiters, - keepViewModeAcrossNotes: loadPrefs().keepViewModeAcrossNotes, - defaultPaneMode: loadPrefs().defaultPaneMode, - syncTitleHeadingOnRename: loadPrefs().syncTitleHeadingOnRename, - markdownSnippets: loadPrefs().markdownSnippets, - textReplacementsEnabled: loadPrefs().textReplacementsEnabled, - textReplacements: loadPrefs().textReplacements, - savedTaskFilters: loadPrefs().savedTaskFilters, - autoPairs: loadPrefs().autoPairs, - autoPairQuotesInProse: loadPrefs().autoPairQuotesInProse, - hideBuiltinTemplates: loadPrefs().hideBuiltinTemplates, - tabsEnabled: loadPrefs().tabsEnabled, - wrapTabs: loadPrefs().wrapTabs, - settingsOpen: false, - workflowTutorialStep: null, - workflowRunRecord: null, - themeId: loadPrefs().themeId, - themeFamily: loadPrefs().themeFamily, - themeMode: loadPrefs().themeMode, - editorFontSize: loadPrefs().editorFontSize, - mathFontScale: loadPrefs().mathFontScale, - editorLineHeight: loadPrefs().editorLineHeight, + const initImpl = async (): Promise => { + if (get().initialized) return + const startedAt = performance.now() + set({ initialized: true }) + let initializedVault = false + try { + const remoteWorkspaceProfilesPromise = get().refreshRemoteWorkspaceProfiles() + const localVaultsPromise = get().refreshLocalVaults() + const [bootWorkspaceInfo, serverCapabilities] = await Promise.all([ + get().refreshWorkspaceContext(), + window.zen.getServerCapabilities().catch(() => null) + ]) + if (!(await ensureWebServerSession(serverCapabilities))) { + void remoteWorkspaceProfilesPromise + void localVaultsPromise + set({ + workspaceMode: workspaceModeFrom(bootWorkspaceInfo), + remoteWorkspaceInfo: bootWorkspaceInfo, + workspaceSetupError: null, + workspaceRestored: true, + vaultSettings: DEFAULT_VAULT_SETTINGS + }) + recordRendererPerf('store.init', performance.now() - startedAt, { + hasVault: false + }) + return + } + const vault = await window.zen.getCurrentVault() + // getCurrentVault is what connects a configured remote workspace, so + // the info fetched above predates the connection: its capabilities and + // bootError are still null, and keeping it would leave Settings + // believing the server advertises nothing (#723). Ask again now that + // the answer exists. + const remoteWorkspaceInfo = bootWorkspaceInfo + ? await get().refreshWorkspaceContext() + : bootWorkspaceInfo + void remoteWorkspaceProfilesPromise + void localVaultsPromise + if (vault) { + const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) + set({ + vault, + workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), + remoteWorkspaceInfo, + workspaceSetupError: null, + vaultSettings, + workspaceRestored: false + }) + await openVaultWorkspace(vault) + await prefetchInitialVisibleNotes(get()) + initializedVault = true + } else { + set({ + workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), + remoteWorkspaceInfo, + workspaceSetupError: null, + workspaceRestored: true, + vaultSettings: DEFAULT_VAULT_SETTINGS + }) + } + } catch (err) { + console.error('init failed', err) + set({ + workspaceMode: 'local', + remoteWorkspaceInfo: null, + workspaceSetupError: + window.zen.getAppInfo().runtime === 'web' ? describeWebServerSetupError(err) : null, + workspaceRestored: true, + vaultSettings: DEFAULT_VAULT_SETTINGS + }) + } + recordRendererPerf('store.init', performance.now() - startedAt, { + hasVault: initializedVault + }) + // Default focus to the sidebar so j/k navigation works immediately + if (get().sidebarOpen && !get().focusedPanel) { + set({ focusedPanel: 'sidebar' }) + } + // Restore the pinned reference note by loading its content — the + // path survived in prefs; `refreshNotes` has already confirmed it + // still exists and otherwise cleared `pinnedRefPath`. + const pinnedPath = get().pinnedRefPath + if (pinnedPath && !get().noteContents[pinnedPath]) { + try { + const content = await readNoteContent(pinnedPath, get()) + set((s) => ({ + noteContents: { ...s.noteContents, [pinnedPath]: content }, + noteDirty: { ...s.noteDirty, [pinnedPath]: false } + })) + } catch (err) { + console.error('pinned reference readNote failed', err) + set({ pinnedRefPath: null }) + savePrefs(collectPrefs(get())) + } + } + // `retryWorkspaceBoot` re-enters `init` on every successful reconnect, so + // the previous subscription has to go before a new one is made. Without + // this each reconnect left a live listener behind and one file change + // arrived as N changes, each running the full `applyChange`. + vaultChangeUnsubscribe?.() + vaultChangeUnsubscribe = window.zen.onVaultChange((ev) => { + void get().applyChange(ev) + }) + } + + const openLocalVaultImpl = async (root: string, requireVault = false): Promise => { + set({ workspaceSetupError: null }) + const vault = await window.zen.openLocalVault(root) + await get().refreshLocalVaults() + if (!vault) { + if (requireVault) throw new Error('The relocated vault could not be opened.') + return + } + + const remoteWorkspaceInfo = await get().refreshWorkspaceContext() + const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) + const fresh = makeLeaf() + set({ + vault, + workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), + remoteWorkspaceInfo, + workspaceSetupError: null, + vaultSettings, + notes: [], + folders: [], + hasAssetsDir: false, + assetFiles: [], + assetUndoStack: [], + closedTabStack: [], + workflowRunRecord: null, + workflowTutorialStep: null, + vaultTasks: [], + selectedTags: [], + view: { kind: 'folder', folder: 'inbox', subpath: '' }, + selectedPath: null, + activeNote: null, + activeDirty: false, + paneLayout: fresh, + activePaneId: fresh.id, + noteContents: {}, + noteDirty: {}, + loadingNote: false, + noteBackstack: [], + noteForwardstack: [], + pendingJumpLocation: null, + pinnedRefPath: null, + workspaceRestored: false + }) + savePrefs(collectPrefs(get())) + await openVaultWorkspace(vault) + } + + const disconnectRemoteWorkspaceImpl = async (): Promise => { + try { + await get().flushDirtyNotes() + const vault = await window.zen.disconnectRemoteWorkspace() + const remoteWorkspaceInfo = await get().refreshWorkspaceContext() + await get().refreshLocalVaults() + + if (!vault) { + const fresh = makeLeaf() + set({ + vault: null, + workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), + remoteWorkspaceInfo, + vaultSettings: DEFAULT_VAULT_SETTINGS, + notes: [], + folders: [], + hasAssetsDir: false, + assetFiles: [], + assetUndoStack: [], + closedTabStack: [], + workflowRunRecord: null, + workflowTutorialStep: null, + vaultTasks: [], + selectedTags: [], + view: { kind: 'folder', folder: 'inbox', subpath: '' }, + selectedPath: null, + activeNote: null, + activeDirty: false, + paneLayout: fresh, + activePaneId: fresh.id, + noteContents: {}, + noteDirty: {}, + loadingNote: false, + noteBackstack: [], + noteForwardstack: [], + pendingJumpLocation: null, + pinnedRefPath: null, + workspaceRestored: true + }) + savePrefs(collectPrefs(get())) + return + } + + const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) + const fresh = makeLeaf() + set({ + vault, + workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), + remoteWorkspaceInfo, + vaultSettings, + notes: [], + folders: [], + hasAssetsDir: false, + assetFiles: [], + assetUndoStack: [], + closedTabStack: [], + workflowRunRecord: null, + workflowTutorialStep: null, + vaultTasks: [], + selectedTags: [], + view: { kind: 'folder', folder: 'inbox', subpath: '' }, + selectedPath: null, + activeNote: null, + activeDirty: false, + paneLayout: fresh, + activePaneId: fresh.id, + noteContents: {}, + noteDirty: {}, + loadingNote: false, + noteBackstack: [], + noteForwardstack: [], + pendingJumpLocation: null, + pinnedRefPath: null, + workspaceRestored: false + }) + savePrefs(collectPrefs(get())) + await openVaultWorkspace(vault) + } catch (error) { + window.alert(error instanceof Error ? error.message : String(error)) + } + } + + return { + vault: null, + workspaceMode: 'local', + remoteWorkspaceInfo: null, + remoteWorkspaceProfiles: [], + localVaults: [], + workspaceSetupError: null, + vaultSettings: DEFAULT_VAULT_SETTINGS, + rootContentHiddenByInboxMode: false, + rootContentBannerDismissed: false, + manualNoteOrder: {}, + notes: [], + typstPreambleNotes: [], + folders: [], + assetFiles: [], + assetUndoStack: [], + hasAssetsDir: false, + view: { kind: 'folder', folder: 'inbox', subpath: '' }, + selectedPath: null, + activeNote: null, + activeDirty: false, + noteBackstack: [], + noteForwardstack: [], + pendingJumpLocation: null, + loadingNote: false, + searchOpen: false, + vaultTextSearchOpen: false, + commandPaletteOpen: false, + commandPaletteInitialMode: 'main', + bufferPaletteOpen: false, + outlinePaletteOpen: false, + templatePaletteOpen: false, + embedDrawingPaletteOpen: false, + excalidrawPreviewVersion: 0, + templatePaletteMode: 'create', + templatePaletteTarget: null, + customTemplates: [], + workflowIndex: [], + query: '', + initialized: false, + workspaceRestored: false, + workspaceTransitioning: false, + sidebarOpen: true, + noteListOpen: true, + zenMode: false, + zenRestoreState: null, + vimMode: loadPrefs().vimMode, + vimInsertEscape: loadPrefs().vimInsertEscape, + ignoredKeys: loadPrefs().ignoredKeys, + externalApplicationSchemes: loadPrefs().externalApplicationSchemes, + vimYankToClipboard: loadPrefs().vimYankToClipboard, + vimBlockImeInNormalMode: loadPrefs().vimBlockImeInNormalMode, + vimWrappedLineMotions: loadPrefs().vimWrappedLineMotions, + keymapOverrides: loadPrefs().keymapOverrides, + enabledOverrides: loadPrefs().enabledOverrides, + themeTweaks: loadPrefs().themeTweaks, + whichKeyHints: loadPrefs().whichKeyHints, + whichKeyHintMode: loadPrefs().whichKeyHintMode, + whichKeyHintTimeoutMs: loadPrefs().whichKeyHintTimeoutMs, + vaultTextSearchBackend: loadPrefs().vaultTextSearchBackend, + ripgrepBinaryPath: loadPrefs().ripgrepBinaryPath, + fzfBinaryPath: loadPrefs().fzfBinaryPath, + livePreview: loadPrefs().livePreview, + showHeadingLevelLabels: loadPrefs().showHeadingLevelLabels, + listIndentGuides: loadPrefs().listIndentGuides, + renderTablesInLivePreview: loadPrefs().renderTablesInLivePreview, + completedTaskStyle: loadPrefs().completedTaskStyle, + mathRenderer: loadPrefs().mathRenderer, + typstTagPreambles: loadPrefs().typstTagPreambles, + harperEnabled: loadPrefs().harperEnabled, + harperDialect: loadPrefs().harperDialect, + harperLintConfig: loadPrefs().harperLintConfig, + looseMathDelimiters: loadPrefs().looseMathDelimiters, + keepViewModeAcrossNotes: loadPrefs().keepViewModeAcrossNotes, + defaultPaneMode: loadPrefs().defaultPaneMode, + syncTitleHeadingOnRename: loadPrefs().syncTitleHeadingOnRename, + markdownSnippets: loadPrefs().markdownSnippets, + textReplacementsEnabled: loadPrefs().textReplacementsEnabled, + textReplacements: loadPrefs().textReplacements, + savedTaskFilters: loadPrefs().savedTaskFilters, + autoPairs: loadPrefs().autoPairs, + autoPairQuotesInProse: loadPrefs().autoPairQuotesInProse, + hideBuiltinTemplates: loadPrefs().hideBuiltinTemplates, + tabsEnabled: loadPrefs().tabsEnabled, + wrapTabs: loadPrefs().wrapTabs, + settingsOpen: false, + workflowTutorialStep: null, + workflowRunRecord: null, + themeId: loadPrefs().themeId, + themeFamily: loadPrefs().themeFamily, + themeMode: loadPrefs().themeMode, + editorFontSize: loadPrefs().editorFontSize, + mathFontScale: loadPrefs().mathFontScale, + editorLineHeight: loadPrefs().editorLineHeight, editorTabSize: loadPrefs().editorTabSize, editorScrollOff: loadPrefs().editorScrollOff, timeFormat: loadPrefs().timeFormat, @@ -4850,6 +5613,7 @@ export const useStore = create((set, get) => { tasksCalendarSelectedDate: null, tasksCalendarMonthAnchor: null, databases: {}, + databasesDeletingRows: {}, databasesLoading: {}, selectedTags: [], tagMatchMode: 'all', @@ -4895,6 +5659,7 @@ export const useStore = create((set, get) => { } }, applyFavorites: async (nextFavorites) => { + const isCurrent = captureFolderActionContext(get) const current = get().vaultSettings if ( current.favorites.length === nextFavorites.length && @@ -4909,10 +5674,10 @@ export const useStore = create((set, get) => { const saved = normalizeVaultSettings( await window.zen.setVaultSettings({ ...get().vaultSettings, favorites: nextFavorites }) ) - set({ vaultSettings: saved }) + if (isCurrent()) set({ vaultSettings: saved }) } catch (err) { console.error('applyFavorites failed', err) - set({ vaultSettings: current }) // revert on failure + if (isCurrent()) set({ vaultSettings: current }) // revert on failure } }, toggleFavorite: async (key) => { @@ -5117,10 +5882,16 @@ export const useStore = create((set, get) => { set({ focusedPanel: 'editor' }) }, loadDatabase: async (csvPath) => { - if (get().databasesLoading[csvPath]) return + if (isWorkspaceTransitionPending()) return + if (get().databasesLoading[csvPath] || databaseRowActions.has(csvPath) || folderMutationBlocks(csvPath)) return + const contextIsCurrent = captureFolderActionContext(get) + const version = databaseLoadVersions.get(csvPath) ?? 0 + const generation = workspaceGeneration() + const isCurrent = () => generation === workspaceGeneration() && contextIsCurrent() && (databaseLoadVersions.get(csvPath) ?? 0) === version set((s) => ({ databasesLoading: { ...s.databasesLoading, [csvPath]: true } })) try { const doc = await window.zen.openDatabase(csvPath) + if (!isCurrent()) return if (!doc) { // The .csv is gone — drop it and close any stale tab rather than leave // a grid pointed at a deleted file (and re-requesting it on every render). @@ -5129,6 +5900,7 @@ export const useStore = create((set, get) => { } set((s) => ({ databases: { ...s.databases, [csvPath]: doc } })) } catch (err) { + if (!isCurrent()) return // Failing silently here is how "clicking a database does nothing" bug // reports happen (#499): the sidebar row looks live, the click dies in // the console. Whatever the cause (server unreachable, bad schema), @@ -5141,35 +5913,71 @@ export const useStore = create((set, get) => { } finally { set((s) => csvPath in s.databasesLoading - ? { databasesLoading: { ...s.databasesLoading, [csvPath]: false } } + && isCurrent() ? { databasesLoading: { ...s.databasesLoading, [csvPath]: false } } : {} ) } }, openDatabase: async (csvPath) => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return await get().loadDatabase(csvPath) + if (!isCurrent()) return // The load may have failed/forgotten a now-missing database — don't open an // empty tab for it. if (!get().databases[csvPath]) return await get().openNoteInPane(get().activePaneId, databaseTabPath(csvPath)) + if (!isCurrent()) return ;(document.activeElement as HTMLElement | null)?.blur?.() set({ focusedPanel: 'editor' }) }, - createDatabase: async (folder, subpath = '', title) => { + createDatabase: async (folder, subpath = '', title, hostIsCurrent) => { + if (workspaceWritesBlocked()) return + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + if (!isCurrent()) return + const directory = vaultRelativeFolderPath(folder, subpath, get().vaultSettings) + const prefix = directory ? `${directory}/` : '' + let release: (() => void) | undefined try { + const busy = [ + ...folderMutations.keys(), + ...databaseCreations.keys(), + ...[...uncertainFolderMutations] + .filter(([, vault]) => vault === get().vault) + .map(([path]) => path) + ] + if (busy.some((path) => prefix.startsWith(path) || path.startsWith(prefix))) + throw new Error('This folder already has an operation in progress') + databaseCreations.set( + prefix, + new Promise((resolve) => { + release = resolve + }) + ) const doc = await window.zen.createDatabase(folder, subpath, title) + if (!isCurrent()) return set((s) => ({ databases: { ...s.databases, [doc.path]: doc } })) + await get().refreshNotes() + if (!isCurrent()) return await get().openNoteInPane(get().activePaneId, databaseTabPath(doc.path)) + if (!isCurrent()) return ;(document.activeElement as HTMLElement | null)?.blur?.() set({ focusedPanel: 'editor' }) } catch (err) { + if (hostIsCurrent) throw err console.error('createDatabase failed', err) - const { useToastStore } = await import('./lib/toast') - useToastStore - .getState() - .addToast(humanIpcError(err, 'Could not create database'), 'error') + if (isCurrent()) { + const { useToastStore } = await import('./lib/toast') + useToastStore.getState().addToast(humanIpcError(err, 'Could not create database'), 'error') + } + } finally { + if (release) { + databaseCreations.delete(prefix) + release() + } } }, + newDatabase: async () => { const s = get() const settings = normalizeVaultSettings(s.vaultSettings) @@ -5180,7 +5988,7 @@ export const useStore = create((set, get) => { ) await get().createDatabase(folder, subpath) }, - newTaskFile: async (opts) => { + newTaskFile: trackNoteWrite(null, async (opts) => { const title = ( await promptApp({ title: 'New task', @@ -5211,7 +6019,7 @@ export const useStore = create((set, get) => { console.error('newTaskFile failed', err) return null } - }, + }), newTaskFileInChosenFolder: async () => { const state = get() const entered = await promptApp(buildNoteDestinationPrompt('', state.folders)) @@ -5219,67 +6027,61 @@ export const useStore = create((set, get) => { const dest = parseTemplateDestination(entered) return get().newTaskFile({ folder: dest.folder, subpath: dest.subpath }) }, - renameDatabase: async (csvPath, newTitle) => { - if (typeof window.zen.renameDatabase !== 'function') return + renameDatabase: async (csvPath, newTitle, hostIsCurrent) => { try { - const newCsvPath = await window.zen.renameDatabase(csvPath, newTitle) - if (!newCsvPath || newCsvPath === csvPath) { - await get().refreshNotes() - return - } - // The `.base` folder moved, so the open grid tab's path changed. Rehome it - // in place (and the cached doc) instead of leaving a stale tab. - const oldTab = databaseTabPath(csvPath) - const newTab = databaseTabPath(newCsvPath) - set((s) => { - const rewrite = (p: string): string => (p === oldTab ? newTab : p) - const ensured = ensureActivePane(rewritePathsInTree(s.paneLayout, rewrite), s.activePaneId) - const databases = { ...s.databases } - const loading = { ...s.databasesLoading } - const prev = databases[csvPath] - if (prev) { - databases[newCsvPath] = { - ...prev, - path: newCsvPath, - title: formTitleFromCsvPath(newCsvPath) + if (newTitle.trim().startsWith('.')) throw new Error('Database names cannot start with a dot.') + if (typeof window.zen.renameDatabase !== 'function') + throw new Error('Database renaming is unavailable') + const directory = formDirFromCsvPath(csvPath) + if (!directory) throw new Error('Only database folders can be renamed') + const settings = get().vaultSettings + const folder = folderForVaultRelativePath(csvPath, settings) ?? 'inbox' + const oldSubpath = noteFolderSubpath({ path: csvPath, folder }, settings) + await renameFolderImpl( + folder, + oldSubpath, + `${directory}/`, + async () => { + const nextPath = await window.zen.renameDatabase(csvPath, newTitle) + const nextDirectory = formDirFromCsvPath(nextPath) + if (!nextDirectory) + throw new Error('FOLDER_STATE_UNCERTAIN: Database rename returned an invalid path') + return { + subpath: noteFolderSubpath({ path: nextPath, folder }, settings), + prefix: `${nextDirectory}/` } - delete databases[csvPath] - } - delete loading[csvPath] - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - databases, - databasesLoading: loading, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, s.noteContents, s.noteDirty) - } - }) - await get().refreshNotes() + }, + hostIsCurrent + ) } catch (err) { + if (hostIsCurrent) throw err console.error('renameDatabase failed', err) window.alert(err instanceof Error ? err.message : String(err)) } }, + updateDatabaseRows: (csvPath, next) => { + if (databaseRowActions.has(csvPath) || isNoteEditingLocked(get().vault, csvPath)) return set((s) => ({ databases: { ...s.databases, [csvPath]: next } })) scheduleDatabaseWrite(csvPath, 'rows', () => get().databases[csvPath]) remirrorOpenRecordPages(csvPath, get) }, deleteDatabaseRows: async (csvPath, rowIds) => { + if (workspaceWritesBlocked()) return const doc = get().databases[csvPath] - if (!doc) return + const vault = get().vault + if (!doc || !vault || databaseRowActions.size > 0 || pendingRowConfirmation || getConfirmRequest() || getPromptRequest()) return + const isCurrent = captureFolderActionContext(get) + const bridge = window.zen const ids = [...new Set(rowIds)].filter((id) => doc.rows.some((r) => r.id === id)) if (ids.length === 0) return - - // Deleted rows that carry a linked record page — the ones worth asking about. - const attached = ids - .map((id) => doc.pages?.[id]) - .filter((p): p is string => typeof p === 'string' && p.length > 0) - + const mappings = new Map(ids.map((id) => [id, doc.pages?.[id]])) + const attached = [...mappings.values()].filter((path): path is string => !!path) let trashNotes = false if (attached.length > 0) { const many = attached.length > 1 - trashNotes = await confirmApp({ + pendingRowConfirmation = true + try { trashNotes = await confirmApp({ title: many ? `Delete ${ids.length} rows and their notes?` : 'Delete row and its linked note?', description: many ? `${attached.length} of these rows have a linked page note. Move those notes to Trash too, or keep them as standalone notes? The rows are deleted either way.` @@ -5287,55 +6089,133 @@ export const useStore = create((set, get) => { confirmLabel: many ? 'Delete rows + notes' : 'Delete row + note', cancelLabel: many ? 'Keep notes' : 'Keep note', danger: true - }) + }) } finally { pendingRowConfirmation = false } } - - // Re-read after the (async) prompt so a concurrent edit isn't clobbered. + if (!isCurrent()) return const latest = get().databases[csvPath] - if (!latest) return + if (!latest || ids.some((id) => !latest.rows.some(row => row.id === id) || latest.pages?.[id] !== mappings.get(id))) { + useToastStore.getState().addToast('The linked pages changed. Review the rows before deleting them.', 'info') + return + } + if (databaseRowActions.size || folderMutations.size || databaseCreations.size || inFlightNoteWrites.size || inFlightTaskMutations.size || taskMutationQueues.size || [...uncertainFolderMutations.values()].includes(vault)) { + useToastStore.getState().addToast('Wait for pending file and task changes before deleting rows.', 'info') + return + } const removeSet = new Set(ids) const nextPages = { ...(latest.pages ?? {}) } const nextFlags = { ...(latest.pageHasContent ?? {}) } - const prunedPaths: string[] = [] for (const id of ids) { - const pagePath = nextPages[id] - if (pagePath) { - prunedPaths.push(pagePath) - delete nextPages[id] - delete nextFlags[id] - } - } - const pagesChanged = prunedPaths.length > 0 + delete nextPages[id] + delete nextFlags[id] + } + const remainingPages = new Set(Object.values(nextPages)) + const directory = formDirFromCsvPath(csvPath) + // Only this database's exclusive pages can be changed automatically. A + // hand-edited foreign/shared mapping must not overwrite another record. + const pages = [...new Set(attached)].filter((path) => + directory && formDirContaining(path) === directory && !remainingPages.has(path) + ) const next: DatabaseDoc = { ...latest, - rows: latest.rows.filter((r) => !removeSet.has(r.id)), - ...(pagesChanged ? { pages: nextPages, pageHasContent: nextFlags } : {}) - } - set((s) => ({ databases: { ...s.databases, [csvPath]: next } })) - // A pruned page mapping lives in the sidecar, so force a schema write; a - // plain 'rows' write only rewrites the CSV and would leave the stale entry. - scheduleDatabaseWrite(csvPath, pagesChanged ? 'schema' : 'rows', () => get().databases[csvPath]) - remirrorOpenRecordPages(csvPath, get) - - if (trashNotes) { - for (const pagePath of prunedPaths) { - await moveNoteToTrash(pagePath, { temporarySession: get().vault?.temporary === true }) + rows: latest.rows.filter((row) => !removeSet.has(row.id)), + pages: nextPages, + pageHasContent: nextFlags + } + let release!: () => void + const done = new Promise((resolve) => { release = resolve }) + databaseRowActions.set(csvPath, done) + set((s) => ({ databasesDeletingRows: { ...s.databasesDeletingRows, [csvPath]: true } })) + databaseLoadVersions.set(csvPath, (databaseLoadVersions.get(csvPath) ?? 0) + 1) + const unlock: Array<() => void> = [] + let committed = false + let moved = 0 + try { + for (const path of pages) unlock.push(lockNoteEditing(vault, path)) + await flushDatabaseWrite(csvPath, () => isCurrent() ? get().databases[csvPath] : undefined) + if (!isCurrent()) return + // Materialize properties while rows still exist. Preserve the freshest + // editor body, including an unsaved page that is open in another pane. + for (const path of pages) { + const pending = pathSaveQueues.get(path) + if (pending) await pending + if (!isCurrent()) return + const content = get().noteContents[path] ?? await bridge.readNote(path) + if (!isCurrent()) return + const row = latest.rows.find((row) => removeSet.has(row.id) && latest.pages?.[row.id] === path) + if (!row) continue + const body = composePageBody(latest, row, parseFrontmatter(content.body).body) + if (get().noteContents[path]) { + set((s) => { + const noteContents = { ...s.noteContents, [path]: { ...s.noteContents[path], body } } + const noteDirty = { ...s.noteDirty, [path]: true } + return { noteContents, noteDirty, ...activeFieldsFrom(s.paneLayout, s.activePaneId, noteContents, noteDirty) } + }) + await get().persistNote(path, true) + if (get().noteDirty[path]) throw new Error('A linked page could not be saved') + } else { + await bridge.writeNote(path, body) + } + if (!isCurrent()) return + } + set((s) => ({ databases: { ...s.databases, [csvPath]: next } })) + databaseWriteKind.set(csvPath, 'schema') + try { + await flushDatabaseWrite(csvPath, () => isCurrent() ? get().databases[csvPath] : undefined) + } catch (error) { + // Keep the recoverable rows and schedule their full schema for retry. + if (isCurrent()) { + set((s) => ({ databases: { ...s.databases, [csvPath]: latest } })) + databaseWriteKind.set(csvPath, 'schema') + } + throw error + } + if (!isCurrent()) return + committed = true + if (trashNotes) { + for (const path of pages) { + if (!isCurrent()) return + const result = await mutateNoteImpl(path, async () => { + const meta = await bridge.moveToTrash(path) + return vault.temporary ? null : meta + }, isCurrent, false, csvPath) + moved += 1 + if (isCurrent() && result && !get().noteDirty[result.path]) + set((s) => withoutNoteInWorkspace(s, result.path)) + } + } + } catch (error) { + const message = committed + ? `Rows deleted; ${moved} linked pages moved. Remaining pages are saved as standalone notes.` + : 'Rows were kept because deletion could not finish.' + useToastStore.getState().addToast(`${message} ${humanIpcError(error, 'Could not finish deleting rows')}`, 'error') + } finally { + for (const restore of unlock) restore() + databaseRowActions.delete(csvPath) + if (isCurrent()) { + set((s) => ({ databasesDeletingRows: { ...s.databasesDeletingRows, [csvPath]: false } })) + const kind = databaseWriteKind.get(csvPath) + if (kind) scheduleDatabaseWrite(csvPath, kind, () => get().databases[csvPath]) } + release() } }, updateDatabaseSchema: (csvPath, next) => { + if (databaseRowActions.has(csvPath) || isNoteEditingLocked(get().vault, csvPath)) return set((s) => ({ databases: { ...s.databases, [csvPath]: next } })) scheduleDatabaseWrite(csvPath, 'schema', () => get().databases[csvPath]) remirrorOpenRecordPages(csvPath, get) }, syncDatabaseFromDisk: async (csvPath) => { - if (!get().databases[csvPath]) return + if (!get().databases[csvPath] || databaseRowActions.has(csvPath) || folderMutationBlocks(csvPath)) return + const contextIsCurrent = captureFolderActionContext(get) + const version = databaseLoadVersions.get(csvPath) ?? 0 // Ignore the watcher echo of a write we just made. if (Date.now() - (lastDatabaseWriteAt.get(csvPath) ?? 0) < 1500) return // Don't clobber edits that are still mid-debounce. - if (databaseSaveTimers.has(csvPath)) return + if (databaseWriteKind.has(csvPath) || databaseWriteQueues.has(csvPath)) return try { const doc = await window.zen.openDatabase(csvPath) + if (!contextIsCurrent() || databaseRowActions.has(csvPath) || (databaseLoadVersions.get(csvPath) ?? 0) !== version || databaseWriteKind.has(csvPath) || databaseWriteQueues.has(csvPath)) return if (!doc) { await get().forgetDatabase(csvPath) return @@ -5367,7 +6247,7 @@ export const useStore = create((set, get) => { return { databases, databasesLoading } }) }, - openRecordPage: async (csvPath, rowId) => { + openRecordPage: trackNoteWrite(undefined, async (csvPath, rowId) => { const doc = get().databases[csvPath] if (!doc) return const row = doc.rows.find((r) => r.id === rowId) @@ -5405,15 +6285,17 @@ export const useStore = create((set, get) => { } } await get().selectNote(pagePath) - }, + }), renameRecordPage: async (csvPath, rowId) => { + const isCurrent = captureFolderActionContext(get) const doc = get().databases[csvPath] const pagePath = doc?.pages?.[rowId] if (!doc || !pagePath) return const row = doc.rows.find((r) => r.id === rowId) if (!row) return try { - const meta = await window.zen.renameNote(pagePath, recordTitle(doc, row)) + const meta = await mutateNoteImpl(pagePath, () => window.zen.renameNote(pagePath, recordTitle(doc, row)), isCurrent, true) + if (!meta || !isCurrent()) return if (meta.path !== pagePath) { get().updateDatabaseSchema(csvPath, { ...get().databases[csvPath]!, @@ -5453,19 +6335,28 @@ export const useStore = create((set, get) => { setTagMatchMode: (mode) => set({ tagMatchMode: mode }), refreshTasks: async () => { + if (folderMutations.size > 0) return + const isCurrent = captureFolderActionContext(get) + const revision = taskIndexRevision set({ tasksLoading: true }) try { const tasks = await window.zen.scanTasks() + if (!isCurrent() || revision !== taskIndexRevision) return set({ vaultTasks: tasks, tasksLoading: false }) } catch (err) { console.error('scanTasks failed', err) + if (!isCurrent() || revision !== taskIndexRevision) return set({ tasksLoading: false }) } }, rescanTasksForPath: async (relPath) => { + if (folderMutationBlocks(relPath)) return + const isCurrent = captureFolderActionContext(get) + const version = folderReadVersion(relPath) try { const fresh = await window.zen.scanTasksForPath(relPath) + if (!isCurrent() || folderMutationBlocks(relPath) || version !== folderReadVersion(relPath)) return set((s) => ({ vaultTasks: s.vaultTasks.filter((t) => t.sourcePath !== relPath).concat(fresh) })) @@ -5475,6 +6366,8 @@ export const useStore = create((set, get) => { }, openTaskAt: async (task) => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return const state = get() // Pull body — in-memory first, disk fallback. Used to resolve lineNumber @@ -5483,6 +6376,7 @@ export const useStore = create((set, get) => { if (!body) { try { const content = await window.zen.readNote(task.sourcePath) + if (!isCurrent()) return body = content.body } catch (err) { console.error('openTaskAt readNote failed', err) @@ -5507,6 +6401,7 @@ export const useStore = create((set, get) => { // tab's content area with the note (the Tasks tab itself stays in the // strip, so the user can hop back with a click). await get().openNoteInPane(state.activePaneId, task.sourcePath) + if (!isCurrent() || get().selectedPath !== task.sourcePath) return // Make sure the folder view is sensible in case the sidebar is visible. if (state.view.kind !== 'folder' || state.view.folder !== task.noteFolder) { set({ view: { kind: 'folder', folder: task.noteFolder, subpath: '' } }) @@ -5530,7 +6425,7 @@ export const useStore = create((set, get) => { requestEditorFocus() }, - toggleTaskFromList: async (task) => { + toggleTaskFromList: trackTaskWrite(async (task) => { const state = get() const path = task.sourcePath const openBuffer = state.noteContents[path] @@ -5570,9 +6465,9 @@ export const useStore = create((set, get) => { : t ) })) - }, + }), - cancelTaskFromList: async (task) => { + cancelTaskFromList: trackTaskWrite(async (task) => { const path = task.sourcePath const openBuffer = get().noteContents[path] const body = openBuffer?.body ?? (await window.zen.readNote(path)).body @@ -5604,9 +6499,9 @@ export const useStore = create((set, get) => { : t ) })) - }, + }), - startTaskFromList: async (task) => { + startTaskFromList: trackTaskWrite(async (task) => { const path = task.sourcePath const openBuffer = get().noteContents[path] const body = openBuffer?.body ?? (await window.zen.readNote(path)).body @@ -5638,9 +6533,9 @@ export const useStore = create((set, get) => { : t ) })) - }, + }), - applyTaskMutation: async (task, mutation) => { + applyTaskMutation: trackTaskWrite(async (task, mutation) => { const mutations: TaskMutation[] = Array.isArray(mutation) ? mutation : [mutation] if (mutations.length === 0) return @@ -5754,51 +6649,47 @@ export const useStore = create((set, get) => { } finally { inFlightTaskMutations.delete(running) } - }, + }), deleteTaskFromList: async (task) => { - const path = task.sourcePath - // A file-task *is* the note, so "delete" means trash the whole note (with a - // confirm, since it may hold body notes). Inline tasks just drop their line. + // File tasks use the note action before entering the inline-task write queue. if (task.kind === 'file') { - if (!(await confirmMoveToTrash(task.noteTitle))) return - set((s) => ({ vaultTasks: s.vaultTasks.filter((t) => t.sourcePath !== path) })) - if (await moveNoteToTrash(path, { temporarySession: get().vault?.temporary === true })) { - await get().refreshNotes() - } - else void get().refreshTasks() - return - } - const openBuffer = get().noteContents[path] - let body: string - try { - body = openBuffer?.body ?? (await window.zen.readNote(path)).body - } catch (err) { - console.error('deleteTaskFromList readNote failed', err) + await get().trashNote(task.sourcePath) return } - const nextBody = removeTaskAtIndex(body, task.taskIndex) - if (nextBody === body) return - // Optimistically drop it from the index so the row vanishes immediately. - set((s) => ({ - vaultTasks: s.vaultTasks.filter( - (t) => !(t.sourcePath === path && t.taskIndex === task.taskIndex) - ) - })) - if (openBuffer) { - get().updateNoteBody(path, nextBody) - } else { + await trackTaskWrite(async () => { + const path = task.sourcePath + const openBuffer = get().noteContents[path] + let body: string try { - await window.zen.writeNote(path, nextBody) - await get().rescanTasksForPath(path) + body = openBuffer?.body ?? (await window.zen.readNote(path)).body } catch (err) { - console.error('deleteTaskFromList writeNote failed', err) - void get().rescanTasksForPath(path) + console.error('deleteTaskFromList readNote failed', err) + return } - } + const nextBody = removeTaskAtIndex(body, task.taskIndex) + if (nextBody === body) return + // Optimistically drop it from the index so the row vanishes immediately. + set((s) => ({ + vaultTasks: s.vaultTasks.filter( + (t) => !(t.sourcePath === path && t.taskIndex === task.taskIndex) + ) + })) + if (openBuffer) { + get().updateNoteBody(path, nextBody) + } else { + try { + await window.zen.writeNote(path, nextBody) + await get().rescanTasksForPath(path) + } catch (err) { + console.error('deleteTaskFromList writeNote failed', err) + void get().rescanTasksForPath(path) + } + } + })() }, - moveTaskToDate: async (task, dateIso) => { + moveTaskToDate: trackTaskWrite(async (task, dateIso) => { const parsed = parseIsoDateLocal(dateIso) if (!parsed) return // A file-task isn't a line that can move into a daily note; rescheduling it @@ -5884,9 +6775,9 @@ export const useStore = create((set, get) => { ...tgtTasks ] })) - }, + }), - forwardTask: async (task, targetPath) => { + forwardTask: trackTaskWrite(async (task, targetPath) => { if (!targetPath || targetPath === task.sourcePath) return const targetMeta = get().notes.find((n) => n.path === targetPath) if (!targetMeta) return @@ -5962,7 +6853,7 @@ export const useStore = create((set, get) => { ...tgtTasks ] })) - }, + }), setTasksFilter: (q) => set({ tasksFilter: q, taskCursorIndex: 0 }), setTasksViewMode: (mode) => { @@ -6122,6 +7013,8 @@ export const useStore = create((set, get) => { }, openNoteAtOffset: async (relPath, offset, options) => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return const state = get() const anchor = Math.max(0, offset) const pendingJumpLocation = { @@ -6141,6 +7034,7 @@ export const useStore = create((set, get) => { ...noteHistoryAfterJump(state, relPath) }) await get().openNoteInPane(state.activePaneId, relPath) + if (!isCurrent()) return set((s) => { if (s.selectedPath === relPath) return { focusedPanel: 'editor' } if (s.pendingJumpLocation?.path === relPath) { @@ -6196,6 +7090,8 @@ export const useStore = create((set, get) => { }, refreshNotes: async () => { + const request = ++noteIndexRequest + const isCurrent = captureFolderActionContext(get) try { // Load this vault's manual note order once per vault (drives #224). const orderRoot = get().vault?.root ?? '' @@ -6209,6 +7105,7 @@ export const useStore = create((set, get) => { window.zen.listFolders(), window.zen.hasAssetsDir() ]) + if (!isCurrent() || request !== noteIndexRequest) return recordRendererPerf('store.refreshNotes.fetch', performance.now() - startedAt, { notes: notes.length, folders: folders.length, @@ -6299,38 +7196,40 @@ export const useStore = create((set, get) => { }, renameAsset: async (relPath, nextName) => { - // Renaming rewrites every note that references the asset on disk. Flush - // open buffers first so that rewrite cannot race a pending save and get - // overwritten by stale editor contents immediately afterwards (#785), the - // same guard `renameNote` uses for inbound wikilinks. - await get().flushDirtyNotes() - if (Object.values(get().noteDirty).some(Boolean)) { - throw new Error('Could not rename while notes still have unsaved changes') - } - const meta = await window.zen.renameAsset(relPath, nextName) - // Assets for the list; notes so `assetEmbeds` (usage) and excerpts follow - // the rewritten bodies. - await Promise.all([get().refreshAssets(), get().refreshNotes()]) - return meta + let result!: AssetMeta + // Link rewrites touch every referencing note. Reserve the vault while + // draining saves and refreshing buffers so neither typing nor a workspace + // switch can overwrite the rewritten links (#785). + await runWorkspaceTransition(async () => { + result = await window.zen.renameAsset(relPath, nextName) + await Promise.all([get().refreshAssets(), get().refreshNotes()]) + await Promise.all(Object.values(get().noteContents).map(({ path, folder }) => + get().applyChange({ kind: 'change', path, folder }) + )) + }, false, true) + return result }, moveAsset: async (relPath, targetDir) => { - // Same guard as renameAsset: the move rewrites referencing notes on disk, - // which must not race a pending save (#785). - await get().flushDirtyNotes() - if (Object.values(get().noteDirty).some(Boolean)) { - throw new Error('Could not move while notes still have unsaved changes') - } - const meta = await window.zen.moveAsset(relPath, targetDir) - await Promise.all([get().refreshAssets(), get().refreshNotes()]) - return meta + let result!: AssetMeta + await runWorkspaceTransition(async () => { + result = await window.zen.moveAsset(relPath, targetDir) + await Promise.all([get().refreshAssets(), get().refreshNotes()]) + await Promise.all(Object.values(get().noteContents).map(({ path, folder }) => + get().applyChange({ kind: 'change', path, folder }) + )) + }, false, true) + return result }, refreshAssets: async () => { + const request = ++assetIndexRequest + const isCurrent = captureFolderActionContext(get) try { const startedAt = performance.now() const [rawAssets, hasAssetsDirOnDisk] = await Promise.all([ window.zen.listAssets(), window.zen.hasAssetsDir() ]) + if (!isCurrent() || request !== assetIndexRequest) return // Hide database internals (sidecar + .bak backups) — they're not // standalone files the user manages. const assetFiles = rawAssets.filter((a) => !isDatabaseInternalPath(a.path)) @@ -6391,6 +7290,7 @@ export const useStore = create((set, get) => { }, applyChange: async (ev) => { + if (folderMutationBlocks(ev.path)) return // The live feed's unlink handling, shared with the resync path below: // a deleted note's tab closes wherever it is open. const closeUnlinkedNote = (notePath: string): void => { @@ -6649,8 +7549,10 @@ export const useStore = create((set, get) => { }, updateNoteBody: (path, body) => { + if (isNoteEditingLocked(get().vault, path)) return set((s) => { const existing = s.noteContents[path] + if (existing) body = rewriteRenamingBody(path, body, existing.folder) if (!existing || existing.body === body) return s const contents = { ...s.noteContents, [path]: { ...existing, body } } const dirty = { ...s.noteDirty, [path]: true } @@ -6668,6 +7570,7 @@ export const useStore = create((set, get) => { ...activeFieldsFrom(layout, s.activePaneId, contents, dirty) } }) + if (folderMutationBlocks(path)) return // Debounced disk write. const existing = pathSaveTimers.get(path) if (existing) clearTimeout(existing) @@ -6686,14 +7589,17 @@ export const useStore = create((set, get) => { await get().persistNote(path) }, - persistNote: async (path) => { + persistNote: async (path, duringFolderMutation = false) => { + const isCurrent = captureFolderActionContext(get) const pending = pathSaveTimers.get(path) if (pending) { clearTimeout(pending) pathSaveTimers.delete(path) } const performWrite = async (): Promise => { + if (!isCurrent()) return const s = get() + if (!duringFolderMutation && folderMutationBlocks(path)) return const content = s.noteContents[path] if (!content || !s.noteDirty[path]) return try { @@ -6702,6 +7608,7 @@ export const useStore = create((set, get) => { const writtenBody = content.body noteContentVersions.set(path, (noteContentVersions.get(path) ?? 0) + 1) const meta = await window.zen.writeNote(path, writtenBody) + if (!isCurrent()) return // Saving a Typst preamble note changes the definitions every note tagged // for it compiles against, so reload and repaint open panes. (#486) if ( @@ -6742,91 +7649,111 @@ export const useStore = create((set, get) => { }, loadNoteComments: async (path) => { - if (!path || isWorkspaceVirtualTabPath(path)) return [] - try { - const comments = await window.zen.readNoteComments(path) - set((s) => ({ - noteComments: { ...s.noteComments, [path]: comments } - })) - return comments - } catch (err) { - console.error('readNoteComments failed', err) - return get().noteComments[path] ?? [] - } + return trackCommentOperation(path, [], async () => { + const isCurrent = captureFolderActionContext(get) + if (!path || isWorkspaceVirtualTabPath(path)) return [] + try { + const comments = await window.zen.readNoteComments(path) + if (!isCurrent()) return [] + set((s) => ({ + noteComments: { ...s.noteComments, [path]: comments } + })) + return comments + } catch (err) { + console.error('readNoteComments failed', err) + return get().noteComments[path] ?? [] + } + }) }, addNoteComment: async (input) => { - const path = input.notePath - if (!path || isWorkspaceVirtualTabPath(path)) return null - const body = input.body.trim() - if (!body) return null - const now = Date.now() - const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) - const draft: NoteCommentInput = { - ...input, - notePath: path, - body, - createdAt: input.createdAt ?? now, - updatedAt: now, - resolvedAt: input.resolvedAt ?? null - } - try { - const comments = await window.zen.writeNoteComments(path, [...current, draft]) - const created = comments[comments.length - 1] ?? null - set((s) => ({ - noteComments: { ...s.noteComments, [path]: comments }, - activeCommentId: created?.id ?? s.activeCommentId - })) - return created - } catch (err) { - console.error('writeNoteComments failed', err) - return null - } + return trackCommentOperation(input.notePath, null, async () => { + const isCurrent = captureFolderActionContext(get) + const path = input.notePath + if (!path || isWorkspaceVirtualTabPath(path)) return null + const body = input.body.trim() + if (!body) return null + const now = Date.now() + const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) + const draft: NoteCommentInput = { + ...input, + notePath: path, + body, + createdAt: input.createdAt ?? now, + updatedAt: now, + resolvedAt: input.resolvedAt ?? null + } + try { + if (!isCurrent()) return null + const comments = await window.zen.writeNoteComments(path, [...current, draft]) + const created = comments[comments.length - 1] ?? null + if (!isCurrent()) return null + set((s) => ({ + noteComments: { ...s.noteComments, [path]: comments }, + activeCommentId: created?.id ?? s.activeCommentId + })) + return created + } catch (err) { + console.error('writeNoteComments failed', err) + return null + } + }) }, updateNoteComment: async (path, id, patch) => { - if (!path || !id) return - const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) - const now = Date.now() - const next = current.map((comment) => - comment.id === id - ? { - ...comment, - ...patch, - body: patch.body !== undefined ? patch.body.trim() : comment.body, - updatedAt: now - } - : comment - ) - try { - const comments = await window.zen.writeNoteComments(path, next) - set((s) => ({ - noteComments: { ...s.noteComments, [path]: comments }, - activeCommentId: - s.activeCommentId && comments.some((comment) => comment.id === s.activeCommentId) - ? s.activeCommentId - : null - })) - } catch (err) { - console.error('updateNoteComment failed', err) - } + return trackCommentOperation(path, undefined, async () => { + const isCurrent = captureFolderActionContext(get) + if (!path || !id) return + const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) + const now = Date.now() + const next = current.map((comment) => + comment.id === id + ? { + ...comment, + ...patch, + body: patch.body !== undefined ? patch.body.trim() : comment.body, + updatedAt: now + } + : comment + ) + try { + if (!isCurrent()) return undefined + const comments = await window.zen.writeNoteComments(path, next) + if (!isCurrent()) return undefined + set((s) => ({ + noteComments: { ...s.noteComments, [path]: comments }, + activeCommentId: + s.activeCommentId && comments.some((comment) => comment.id === s.activeCommentId) + ? s.activeCommentId + : null + })) + } catch (err) { + console.error('updateNoteComment failed', err) + } + }) }, deleteNoteComment: async (path, id) => { - if (!path || !id) return - const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) - const next = current.filter((comment) => comment.id !== id) - try { - const comments = await window.zen.writeNoteComments(path, next) - set((s) => ({ - noteComments: { ...s.noteComments, [path]: comments }, - activeCommentId: s.activeCommentId === id ? null : s.activeCommentId - })) - } catch (err) { - console.error('deleteNoteComment failed', err) - } + return trackCommentOperation(path, undefined, async () => { + const isCurrent = captureFolderActionContext(get) + if (!path || !id) return + const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) + const next = current.filter((comment) => comment.id !== id) + try { + if (!isCurrent()) return undefined + const comments = await window.zen.writeNoteComments(path, next) + if (!isCurrent()) return undefined + set((s) => ({ + noteComments: { ...s.noteComments, [path]: comments }, + activeCommentId: s.activeCommentId === id ? null : s.activeCommentId + })) + } catch (err) { + console.error('deleteNoteComment failed', err) + } + }) }, + setActiveCommentId: (id) => set({ activeCommentId: id }), formatActiveNote: async () => { @@ -6845,32 +7772,12 @@ export const useStore = create((set, get) => { } }, - renameNote: async (oldPath, nextTitle) => { + renameNote: async (oldPath, nextTitle, hostIsCurrent) => { if (!oldPath) return try { - // Renaming rewrites every inbound wikilink on disk. Flush open buffers - // first so that rewrite cannot race a pending save and get overwritten - // by stale editor contents immediately afterwards. - await get().flushDirtyNotes() - if (Object.values(get().noteDirty).some(Boolean)) { - throw new Error('Could not rename while notes still have unsaved changes') - } - renamesInFlight.add(oldPath) - let meta: NoteMeta - try { - meta = await window.zen.renameNote(oldPath, nextTitle) - set((s) => renameNoteState(s, oldPath, meta)) - } finally { - renamesInFlight.delete(oldPath) - } - await get().applyFavorites( - rewriteFavoriteNotePath(get().vaultSettings.favorites, oldPath, meta.path) - ) - // Before the refresh so one listing picks up both the rename and the - // rewritten heading (excerpt, size). - await syncHeadingAfterRename(meta, get) - await get().refreshNotes() + await mutateNoteImpl(oldPath, () => window.zen.renameNote(oldPath, nextTitle), hostIsCurrent, true) } catch (err) { + if (hostIsCurrent) throw err console.error('renameNote failed', err) } }, @@ -6881,7 +7788,7 @@ export const useStore = create((set, get) => { await get().renameNote(oldPath, nextTitle) }, - createAndOpen: async (folder, subpath = '', options) => { + createAndOpen: trackNoteWrite(undefined, async (folder, subpath = '', options) => { try { const meta = await window.zen.createNote(folder, options?.title, subpath) rememberEditModeForCreatedNote(meta.path) @@ -6894,9 +7801,9 @@ export const useStore = create((set, get) => { } catch (err) { console.error('createNote failed', err) } - }, + }), - createDrawingAndOpen: async (folder, subpath = '') => { + createDrawingAndOpen: trackNoteWrite(undefined, async (folder, subpath = '') => { try { const meta = await window.zen.createExcalidraw(folder, subpath) await get().refreshNotes() @@ -6905,7 +7812,7 @@ export const useStore = create((set, get) => { } catch (err) { console.error('createExcalidraw failed', err) } - }, + }), insertEmbedAtCursor: (embed) => { const state = get() @@ -6920,7 +7827,7 @@ export const useStore = create((set, get) => { view.focus() }, - newDrawing: async () => { + newDrawing: trackNoteWrite(undefined, async () => { try { const s = get() const settings = normalizeVaultSettings(s.vaultSettings) @@ -6935,9 +7842,9 @@ export const useStore = create((set, get) => { } catch (err) { console.error('newDrawing failed', err) } - }, + }), - embedNewDrawing: async () => { + embedNewDrawing: trackNoteWrite(undefined, async () => { try { const s = get() const settings = normalizeVaultSettings(s.vaultSettings) @@ -6955,7 +7862,7 @@ export const useStore = create((set, get) => { } catch (err) { console.error('embedNewDrawing failed', err) } - }, + }), createNoteInCurrentFolder: async () => { const s = get() @@ -6985,7 +7892,7 @@ export const useStore = create((set, get) => { await get().createAndOpen(dest.folder, dest.subpath, { focusTitle: true }) }, - importDroppedMarkdownFiles: async (files) => { + importDroppedMarkdownFiles: trackNoteWrite(undefined, async (files) => { const createdPaths: string[] = [] for (const file of files) { try { @@ -7001,7 +7908,7 @@ export const useStore = create((set, get) => { if (createdPaths.length === 0) return await get().refreshNotes() for (const path of createdPaths) await get().openNoteInTab(path) - }, + }), closeActiveNote: async () => { const state = get() @@ -7039,113 +7946,125 @@ export const useStore = create((set, get) => { }, trashNote: async (path) => { - const state = get() - const title = state.notes.find((note) => note.path === path)?.title - if (!(await confirmMoveToTrash(title))) return false - if (!(await moveNoteToTrash(path, { temporarySession: state.vault?.temporary === true }))) { + const isCurrent = captureFolderActionContext(get) + const title = get().notes.find((note) => note.path === path)?.title + if (!(await confirmMoveToTrash(title, get().vault?.temporary === true)) || !isCurrent() || !get().notes.some(note => note.path === path)) return false + try { + await get().changeNoteLifecycle(path, 'trash', isCurrent) + return isCurrent() + } catch (error) { + useToastStore.getState().addToast(humanIpcError(error, 'Could not move the note to Trash.'), 'error') return false } - set((s) => withoutNoteInWorkspace(s, path)) - await get().refreshNotes() - return true }, deleteActivePermanently: async () => { const path = get().selectedPath - if (!path) return - await get().deleteNotePermanently(path) + if (path) await get().deleteNotePermanently(path) }, deleteNotePermanently: async (path) => { + const isCurrent = captureFolderActionContext(get) const title = get().notes.find((note) => note.path === path)?.title - if (!(await confirmDeletePermanently(title))) return false - if (!(await deleteNotePermanently(path))) return false - set((s) => withoutNoteInWorkspace(s, path)) - await get().refreshNotes() - return true + if (!(await confirmDeletePermanently(title)) || !isCurrent() || !get().notes.some(note => note.path === path)) return false + try { + await get().changeNoteLifecycle(path, 'delete', isCurrent) + return isCurrent() + } catch (error) { + useToastStore.getState().addToast(`Could not delete: ${humanIpcError(error, 'the note could not be deleted.')}`, 'error') + return false + } + }, + + emptyTrash: async (hostIsCurrent) => { + const vault = get().vault + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + const canReconcile = captureFolderActionContext(get) + if (!vault || !isCurrent()) return + if (inFlightTaskMutations.size || taskMutationQueues.size) + throw new Error('Wait for pending task changes before emptying Trash.') + const prefix = `${vaultRelativeFolderPath('trash', '', get().vaultSettings)}/` + const bridge = window.zen + const unlock = lockNoteEditing(vault, prefix) + try { + await mutateFolderContents(get, prefix, isCurrent, canReconcile, async () => { + await bridge.emptyTrash() + if (!canReconcile()) return null + set(s => ({ + ...rewriteFolderWorkspace(s, prefix, null), + notes: s.notes.filter(note => !note.path.startsWith(prefix)), + folders: s.folders.filter(folder => folder.folder !== 'trash'), + view: s.view.kind === 'folder' && s.view.folder === 'trash' + ? {kind:'folder',folder:'trash',subpath:''} : s.view + })) + savePrefs(collectPrefs(get())) + writeManualOrder(vault.root, get().manualNoteOrder) + await get().applyFavorites(get().vaultSettings.favorites.filter(path => !path.startsWith(prefix))) + if (isCurrent()) await get().refreshNotes() + return null + }) + } finally {unlock()} + }, + + changeNoteLifecycle: async (path, action, hostIsCurrent) => { + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + const canReconcile = captureFolderActionContext(get) + const source = get().notes.find(note => note.path === path) + if (!source || !isCurrent()) return null + const bridge = window.zen + const systemTrash = action === 'trash' && get().vault?.temporary === true + if (action === 'delete' || systemTrash) { + const unlock = lockNoteEditing(get().vault!, path) + let committed = false + try { + await mutateNoteImpl(path, async () => { + if (systemTrash) await bridge.moveToTrash(path) + else await bridge.deleteNote(path) + committed = true + return null + }, isCurrent) + if (systemTrash && committed && canReconcile()) { + useToastStore.getState().addToast('Moved to system Trash', 'info') + } + return null + } finally { + unlock() + } + } + const meta = await mutateNoteImpl(path, () => { + if (action === 'archive') return bridge.archiveNote(path) + if (action === 'trash') return bridge.moveToTrash(path) + return source.folder === 'archive' ? bridge.unarchiveNote(path) : bridge.restoreFromTrash(path) + }, isCurrent) + if (meta && canReconcile() && (action === 'archive' || action === 'trash')) { + if (get().noteDirty[meta.path]) throw new Error('The moved note still has unsaved changes.') + set(s => withoutNoteInWorkspace(s, meta.path)) + savePrefs(collectPrefs(get())) + } + return meta }, restoreActive: async () => { const path = get().selectedPath if (!path) return - const meta = await window.zen.restoreFromTrash(path) - await get().refreshNotes() - set((s) => { - const rewrite = (p: string): string => (p === path ? meta.path : p) - const nextLayout = rewritePathsInTree(s.paneLayout, rewrite) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents = { ...s.noteContents } - const dirty = { ...s.noteDirty } - const prevContent = contents[path] - if (path !== meta.path) { - delete contents[path] - delete dirty[path] - } - if (prevContent) { - contents[meta.path] = { ...prevContent, ...meta } - } - dirty[meta.path] = false - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - noteBackstack: rewriteNoteJumpHistory(s.noteBackstack, rewrite), - noteForwardstack: rewriteNoteJumpHistory(s.noteForwardstack, rewrite), - pendingJumpLocation: - s.pendingJumpLocation?.path === path - ? { ...s.pendingJumpLocation, path: meta.path } - : s.pendingJumpLocation, - pinnedRefPath: s.pinnedRefPath === path ? meta.path : s.pinnedRefPath, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } - }) + try { await get().changeNoteLifecycle(path, 'restore') } + catch (error) { useToastStore.getState().addToast(humanIpcError(error, 'Could not restore the note.'), 'error') } }, archiveActive: async () => { const path = get().selectedPath if (!path) return - if (!(await get().confirmArchiveNotes([path]))) return - await window.zen.archiveNote(path) - set((s) => withoutNoteInWorkspace(s, path)) - await get().refreshNotes() + const isCurrent = captureFolderActionContext(get) + if (!(await get().confirmArchiveNotes([path])) || !isCurrent()) return + try { await get().changeNoteLifecycle(path, 'archive', isCurrent) } + catch (error) { useToastStore.getState().addToast(humanIpcError(error, 'Could not archive the note.'), 'error') } }, unarchiveActive: async () => { const path = get().selectedPath if (!path) return - const meta = await window.zen.unarchiveNote(path) - await get().refreshNotes() - set((s) => { - const rewrite = (p: string): string => (p === path ? meta.path : p) - const nextLayout = rewritePathsInTree(s.paneLayout, rewrite) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents = { ...s.noteContents } - const dirty = { ...s.noteDirty } - const prevContent = contents[path] - if (path !== meta.path) { - delete contents[path] - delete dirty[path] - } - if (prevContent) { - contents[meta.path] = { ...prevContent, ...meta } - } - dirty[meta.path] = false - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - noteBackstack: rewriteNoteJumpHistory(s.noteBackstack, rewrite), - noteForwardstack: rewriteNoteJumpHistory(s.noteForwardstack, rewrite), - pendingJumpLocation: - s.pendingJumpLocation?.path === path - ? { ...s.pendingJumpLocation, path: meta.path } - : s.pendingJumpLocation, - pinnedRefPath: s.pinnedRefPath === path ? meta.path : s.pinnedRefPath, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } - }) + try { await get().changeNoteLifecycle(path, 'restore') } + catch (error) { useToastStore.getState().addToast(humanIpcError(error, 'Could not restore the note.'), 'error') } }, exportActiveNoteDocx: async () => { @@ -7759,7 +8678,7 @@ export const useStore = create((set, get) => { set({ manualNoteOrder: nextMap }) writeManualOrder(s.vault?.root ?? '', nextMap) }, - reorderTaskInNote: async (task, targetTask, position) => { + reorderTaskInNote: trackTaskWrite(async (task, targetTask, position) => { // Reorder is a within-note line move — tasks in different notes live in // different files, so cross-note moves aren't possible here. if (task.sourcePath !== targetTask.sourcePath || task.taskIndex === targetTask.taskIndex) { @@ -7798,7 +8717,7 @@ export const useStore = create((set, get) => { void get().rescanTasksForPath(path) } } - }, + }), setGroupByKind: (on) => { set({ groupByKind: on }) savePrefs(collectPrefs(get())) @@ -8020,7 +8939,7 @@ export const useStore = create((set, get) => { await get().openDailyNoteForDate(new Date()) }, - ensureDailyNoteForDate: async (date) => { + ensureDailyNoteForDate: trackNoteWrite(null, async (date) => { const state = get() const settings = normalizeVaultSettings(state.vaultSettings) if (!settings.dailyNotes.enabled) return null @@ -8039,9 +8958,9 @@ export const useStore = create((set, get) => { console.error('ensureDailyNoteForDate failed', err) return null } - }, + }), - addTaskForDate: async (dateIso, text) => { + addTaskForDate: trackNoteWrite(undefined, async (dateIso, text) => { const content = text.trim() if (!content) return const parsed = parseIsoDateLocal(dateIso) @@ -8082,9 +9001,9 @@ export const useStore = create((set, get) => { console.error('addTaskForDate writeNote failed', err) } } - }, + }), - rolloverUnfinishedTasksIntoToday: async (opts) => { + rolloverUnfinishedTasksIntoToday: trackNoteWrite(0, async (opts) => { const force = opts?.force === true const settings = normalizeVaultSettings(get().vaultSettings) if (!settings.dailyNotes.enabled) return 0 @@ -8174,7 +9093,7 @@ export const useStore = create((set, get) => { } writeRolloverMarker(vaultRoot, todayIso) return movedLines.length - }, + }), openWeeklyNoteForDate: async (date) => { const state = get() @@ -8296,7 +9215,7 @@ export const useStore = create((set, get) => { await get().loadCustomTemplates() }, - createFromTemplate: async (template, opts) => { + createFromTemplate: trackNoteWrite(undefined, async (template, opts) => { try { // 1. Destination. An explicit folder (e.g. right-click on a folder) is // used directly; otherwise prompt, defaulting to the vault root so the @@ -8359,7 +9278,7 @@ export const useStore = create((set, get) => { } catch (err) { console.error('createFromTemplate failed', err) } - }, + }), saveActiveNoteAsTemplate: async () => { const active = get().activeNote @@ -8377,7 +9296,7 @@ export const useStore = create((set, get) => { await get().saveCustomTemplate({ slug: slugifyTemplateName(trimmed), raw }) }, - saveActiveNoteAs: async (newName: string) => { + saveActiveNoteAs: trackNoteWrite(undefined, async (newName: string) => { const active = get().activeNote const notePath = active?.path if (!active || !notePath) return @@ -8406,7 +9325,7 @@ export const useStore = create((set, get) => { } catch (err) { window.alert(err instanceof Error ? err.message : String(err)) } - }, + }), setWordWrap: (on) => { set({ wordWrap: on }) @@ -8521,6 +9440,8 @@ export const useStore = create((set, get) => { }, focusTabInPane: async (paneId, path) => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return const s = get() const leaf = findLeaf(s.paneLayout, paneId) if (!leaf) return @@ -8531,6 +9452,8 @@ export const useStore = create((set, get) => { if (s.noteDirty[s.selectedPath]) await get().persistNote(s.selectedPath) } + if (!isCurrent()) return + // Virtual Workflows tab. Same deal as Tasks below: `zen://workflows` is not // a file, so it must short-circuit before the disk read or readNote tries to // open `/zen:/workflows` and the tab never opens. @@ -8667,7 +9590,9 @@ export const useStore = create((set, get) => { if (needContent) { set({ loadingNote: paneId === s.activePaneId }) try { + const scope = noteReadCacheKey(s, path) const content = await readNoteContent(path, s) + if (!isCurrent() || noteReadCacheKey(get(), path) !== scope) return set((cur) => { const contents = { ...cur.noteContents, [path]: content } const dirty = { ...cur.noteDirty, [path]: false } @@ -8685,6 +9610,7 @@ export const useStore = create((set, get) => { }) } catch (err) { console.error('focusTabInPane readNote failed', err) + if (!isCurrent()) return set({ loadingNote: false }) } return @@ -8703,6 +9629,8 @@ export const useStore = create((set, get) => { }, openNoteInPane: async (paneId, path, insertIndex) => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return const s = get() const leaf = findLeaf(s.paneLayout, paneId) if (!leaf) return @@ -8728,7 +9656,9 @@ export const useStore = create((set, get) => { } if (!s.noteContents[path]) { try { + const scope = noteReadCacheKey(s, path) const content = await readNoteContent(path, s) + if (!isCurrent() || noteReadCacheKey(get(), path) !== scope) return set((cur) => { const contents = { ...cur.noteContents, [path]: content } const dirty = { ...cur.noteDirty, [path]: false } @@ -9017,162 +9947,83 @@ export const useStore = create((set, get) => { clearPendingTitleFocus: () => set({ pendingTitleFocusPath: null }), clearPendingJumpLocation: () => set({ pendingJumpLocation: null }), - renameTag: async (oldTag, newTag) => { + renameTag: trackNoteWrite(undefined, async (oldTag, newTag) => { await rewriteTagAcrossVault(get, oldTag, newTag) - }, - deleteTag: async (tag) => { + }), + deleteTag: trackNoteWrite(undefined, async (tag) => { await rewriteTagAcrossVault(get, tag, null) - }, + }), - createFolder: async (folder, subpath) => { + createFolder: async (folder, subpath, hostIsCurrent) => { + if (workspaceWritesBlocked()) return + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + if (!isCurrent()) return + noteIndexRequest += 1 await window.zen.createFolder(folder, subpath) + if (!isCurrent()) return await get().refreshNotes() - set({ view: { kind: 'folder', folder, subpath } }) + if (isCurrent()) set({ view: { kind: 'folder', folder, subpath } }) }, - renameFolder: async (folder, oldSubpath, newSubpath) => { - await window.zen.renameFolder(folder, oldSubpath, newSubpath) - - const folderPath = resolveFolderPath(folder, get().vaultSettings.systemFolderPaths) - const oldPrefix = `${folderPath}/${oldSubpath}/` - const newPrefix = `${folderPath}/${newSubpath}/` - const rewritePath = (p: string): string => - p.toLowerCase().startsWith(oldPrefix.toLowerCase()) - ? newPrefix + p.slice(oldPrefix.length) - : p - - const notes = get().notes.map((n) => - n.path.toLowerCase().startsWith(oldPrefix.toLowerCase()) ? { ...n, path: rewritePath(n.path) } : n - ) - const folders = get().folders.map((f) => { - if (f.folder !== folder) return f - if (f.subpath === oldSubpath) return { ...f, subpath: newSubpath } - if (f.subpath.startsWith(`${oldSubpath}/`)) { - return { ...f, subpath: newSubpath + f.subpath.slice(oldSubpath.length) } - } - return f - }) - const nextFolderIcons = rewriteFolderIconsForRename( - get().vaultSettings.folderIcons, - folder, - oldSubpath, - newSubpath - ) - const nextFolderColors = rewriteFolderColorsForRename( - get().vaultSettings.folderColors, + renameFolder: async (folder, oldSubpath, requestedSubpath, hostIsCurrent) => { + const settings = get().vaultSettings + await renameFolderImpl( folder, oldSubpath, - newSubpath - ) - set((s) => { - const nextLayout = rewritePathsInTree(s.paneLayout, rewritePath) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents: Record = {} - const dirty: Record = {} - for (const [path, content] of Object.entries(s.noteContents)) { - const next = rewritePath(path) - contents[next] = path === next ? content : { ...content, path: next } - dirty[next] = s.noteDirty[path] ?? false - } - return { - notes, - folders, - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - noteBackstack: rewriteNoteJumpHistory(s.noteBackstack, rewritePath), - noteForwardstack: rewriteNoteJumpHistory(s.noteForwardstack, rewritePath), - pendingJumpLocation: s.pendingJumpLocation - ? { ...s.pendingJumpLocation, path: rewritePath(s.pendingJumpLocation.path) } - : null, - pinnedRefPath: s.pinnedRefPath ? rewritePath(s.pinnedRefPath) : null, - vaultSettings: { - ...s.vaultSettings, - folderIcons: nextFolderIcons, - folderColors: nextFolderColors - }, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } - }) - - // Repoint favorites at the renamed folder (its own key, descendant folder - // keys, and note favorites that lived under it) and persist. - await get().applyFavorites( - rewriteFavoritesForFolderRename( - get().vaultSettings.favorites, - folder, - oldSubpath, - newSubpath, - oldPrefix, - newPrefix - ) + `${vaultRelativeFolderPath(folder, oldSubpath, settings)}/`, + async () => { + const subpath = await window.zen.renameFolder(folder, oldSubpath, requestedSubpath) + return { subpath, prefix: `${vaultRelativeFolderPath(folder, subpath, settings)}/` } + }, + hostIsCurrent ) - - await get().refreshNotes() - - const v = get().view - if (v.kind === 'folder' && v.folder === folder) { - if (v.subpath === oldSubpath) { - set({ view: { ...v, subpath: newSubpath } }) - } else if (v.subpath.startsWith(`${oldSubpath}/`)) { - const tail = v.subpath.slice(oldSubpath.length + 1) - set({ view: { ...v, subpath: `${newSubpath}/${tail}` } }) - } - } }, - deleteFolder: async (folder, subpath) => { - await window.zen.deleteFolder(folder, subpath) - await get().refreshNotes() - const v = get().view - if ( - v.kind === 'folder' && - v.folder === folder && - (v.subpath === subpath || v.subpath.startsWith(`${subpath}/`)) - ) { - set({ view: { kind: 'folder', folder, subpath: '' } }) - } - const folderPath = resolveFolderPath(folder, get().vaultSettings.systemFolderPaths) - const prefix = `${folderPath}/${subpath}/` - const nextFolderIcons = removeFolderIcons(get().vaultSettings.folderIcons, folder, subpath) - const nextFolderColors = removeFolderColors(get().vaultSettings.folderColors, folder, subpath) - set((s) => { - const nextLayout = rewritePathsInTree(s.paneLayout, (p) => - p.startsWith(prefix) ? null : p - ) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents: Record = {} - const dirty: Record = {} - for (const [path, content] of Object.entries(s.noteContents)) { - if (!path.startsWith(prefix)) { - contents[path] = content - dirty[path] = s.noteDirty[path] ?? false - } - } - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - pendingJumpLocation: null, - pinnedRefPath: - s.pinnedRefPath && s.pinnedRefPath.startsWith(prefix) ? null : s.pinnedRefPath, + deleteFolder: async (folder, subpath, hostIsCurrent) => { + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + const canReconcile = captureFolderActionContext(get) + if (!isCurrent()) return + const prefix = `${vaultRelativeFolderPath(folder, subpath, get().vaultSettings)}/` + await mutateFolderContents(get, prefix, isCurrent, canReconcile, async () => { + await window.zen.deleteFolder(folder, subpath) + if (!canReconcile()) return + noteIndexRequest += 1 + taskIndexRevision += 1 + assetIndexRequest += 1 + set((s) => ({ + ...rewriteFolderWorkspace(s, prefix, null), + notes: s.notes.filter((note) => !note.path.startsWith(prefix)), + folders: s.folders.filter( + (entry) => + entry.folder !== folder || + (entry.subpath !== subpath && !entry.subpath.startsWith(`${subpath}/`)) + ), + view: + s.view.kind === 'folder' && + s.view.folder === folder && + (s.view.subpath === subpath || s.view.subpath.startsWith(`${subpath}/`)) + ? { kind: 'folder', folder, subpath: '' } + : s.view, vaultSettings: { ...s.vaultSettings, - folderIcons: nextFolderIcons, - folderColors: nextFolderColors - }, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } + folderIcons: removeFolderIcons(s.vaultSettings.folderIcons, folder, subpath), + folderColors: removeFolderColors(s.vaultSettings.folderColors, folder, subpath) + } + })) + savePrefs(collectPrefs(get())) + writeManualOrder(get().vault?.root ?? '', get().manualNoteOrder) + await get().applyFavorites( + removeFavoritesForFolder(get().vaultSettings.favorites, folder, subpath, prefix) + ) + if (!isCurrent()) return null + await get().refreshNotes() + if (!isCurrent()) return null + return null }) - // Drop favorites for the deleted folder and the notes that lived under it. - await get().applyFavorites( - removeFavoritesForFolder(get().vaultSettings.favorites, folder, subpath, prefix) - ) }, - duplicateFolder: async (folder, subpath) => { + + duplicateFolder: trackNoteWrite(undefined, async (folder, subpath) => { const newSubpath = await window.zen.duplicateFolder(folder, subpath) await get().refreshNotes() set((s) => ({ @@ -9193,7 +10044,7 @@ export const useStore = create((set, get) => { ) } })) - }, + }), revealFolder: async (folder, subpath) => { await window.zen.revealFolder(folder, subpath) @@ -9203,44 +10054,11 @@ export const useStore = create((set, get) => { await window.zen.revealAssetsDir() }, - moveNote: async (relPath, targetFolder, targetSubpath) => { + moveNote: async (relPath, targetFolder, targetSubpath, hostIsCurrent) => { try { - const meta = await window.zen.moveNote(relPath, targetFolder, targetSubpath) - await get().refreshNotes() - set((s) => { - const rewrite = (p: string): string => (p === relPath ? meta.path : p) - const nextLayout = rewritePathsInTree(s.paneLayout, rewrite) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents = { ...s.noteContents } - const dirty = { ...s.noteDirty } - const prev = contents[relPath] - if (relPath !== meta.path) { - delete contents[relPath] - delete dirty[relPath] - } - if (prev) { - contents[meta.path] = { ...prev, ...meta } - dirty[meta.path] = s.noteDirty[relPath] ?? false - } - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - noteBackstack: rewriteNoteJumpHistory(s.noteBackstack, rewrite), - noteForwardstack: rewriteNoteJumpHistory(s.noteForwardstack, rewrite), - pendingJumpLocation: - s.pendingJumpLocation?.path === relPath - ? { ...s.pendingJumpLocation, path: meta.path } - : s.pendingJumpLocation, - pinnedRefPath: s.pinnedRefPath === relPath ? meta.path : s.pinnedRefPath, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } - }) - await get().applyFavorites( - rewriteFavoriteNotePath(get().vaultSettings.favorites, relPath, meta.path) - ) + await mutateNoteImpl(relPath, () => window.zen.moveNote(relPath, targetFolder, targetSubpath), hostIsCurrent) } catch (err) { + if (hostIsCurrent) throw err console.error('moveNote failed', err) } }, @@ -9295,112 +10113,9 @@ export const useStore = create((set, get) => { } }, - init: async () => { - if (get().initialized) return - const startedAt = performance.now() - set({ initialized: true }) - let initializedVault = false - try { - const remoteWorkspaceProfilesPromise = get().refreshRemoteWorkspaceProfiles() - const localVaultsPromise = get().refreshLocalVaults() - const [bootWorkspaceInfo, serverCapabilities] = await Promise.all([ - get().refreshWorkspaceContext(), - window.zen.getServerCapabilities().catch(() => null) - ]) - if (!(await ensureWebServerSession(serverCapabilities))) { - void remoteWorkspaceProfilesPromise - void localVaultsPromise - set({ - workspaceMode: workspaceModeFrom(bootWorkspaceInfo), - remoteWorkspaceInfo: bootWorkspaceInfo, - workspaceSetupError: null, - workspaceRestored: true, - vaultSettings: DEFAULT_VAULT_SETTINGS - }) - recordRendererPerf('store.init', performance.now() - startedAt, { - hasVault: false - }) - return - } - const vault = await window.zen.getCurrentVault() - // getCurrentVault is what connects a configured remote workspace, so - // the info fetched above predates the connection: its capabilities and - // bootError are still null, and keeping it would leave Settings - // believing the server advertises nothing (#723). Ask again now that - // the answer exists. - const remoteWorkspaceInfo = bootWorkspaceInfo - ? await get().refreshWorkspaceContext() - : bootWorkspaceInfo - void remoteWorkspaceProfilesPromise - void localVaultsPromise - if (vault) { - const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) - set({ - vault, - workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), - remoteWorkspaceInfo, - workspaceSetupError: null, - vaultSettings, - workspaceRestored: false - }) - await openVaultWorkspace(vault) - await prefetchInitialVisibleNotes(get()) - initializedVault = true - } else { - set({ - workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), - remoteWorkspaceInfo, - workspaceSetupError: null, - workspaceRestored: true, - vaultSettings: DEFAULT_VAULT_SETTINGS - }) - } - } catch (err) { - console.error('init failed', err) - set({ - workspaceMode: 'local', - remoteWorkspaceInfo: null, - workspaceSetupError: - window.zen.getAppInfo().runtime === 'web' ? describeWebServerSetupError(err) : null, - workspaceRestored: true, - vaultSettings: DEFAULT_VAULT_SETTINGS - }) - } - recordRendererPerf('store.init', performance.now() - startedAt, { - hasVault: initializedVault - }) - // Default focus to the sidebar so j/k navigation works immediately - if (get().sidebarOpen && !get().focusedPanel) { - set({ focusedPanel: 'sidebar' }) - } - // Restore the pinned reference note by loading its content — the - // path survived in prefs; `refreshNotes` has already confirmed it - // still exists and otherwise cleared `pinnedRefPath`. - const pinnedPath = get().pinnedRefPath - if (pinnedPath && !get().noteContents[pinnedPath]) { - try { - const content = await readNoteContent(pinnedPath, get()) - set((s) => ({ - noteContents: { ...s.noteContents, [pinnedPath]: content }, - noteDirty: { ...s.noteDirty, [pinnedPath]: false } - })) - } catch (err) { - console.error('pinned reference readNote failed', err) - set({ pinnedRefPath: null }) - savePrefs(collectPrefs(get())) - } - } - // `retryWorkspaceBoot` re-enters `init` on every successful reconnect, so - // the previous subscription has to go before a new one is made. Without - // this each reconnect left a live listener behind and one file change - // arrived as N changes, each running the full `applyChange`. - vaultChangeUnsubscribe?.() - vaultChangeUnsubscribe = window.zen.onVaultChange((ev) => { - void get().applyChange(ev) - }) - }, + init: () => get().initialized ? Promise.resolve() : runWorkspaceTransition(initImpl, true), - retryWorkspaceBoot: async () => { + retryWorkspaceBoot: () => runWorkspaceTransition(async () => { set({ workspaceSetupError: null }) try { const vault = await window.zen.retryWorkspaceBoot() @@ -9409,7 +10124,7 @@ export const useStore = create((set, get) => { // vault, settings, indexes and session restore land the normal way. // init() is once-guarded for real boots; this re-entry is the point. set({ initialized: false }) - await get().init() + await initImpl() return } // Still down. Refresh the info so the screen shows the latest reason. @@ -9418,9 +10133,9 @@ export const useStore = create((set, get) => { console.error('retryWorkspaceBoot failed', err) set({ workspaceSetupError: humanIpcError(err, 'Could not reach the server.') }) } - }, + }), - openVaultPicker: async () => { + openVaultPicker: () => runWorkspaceTransition(async () => { await get().flushDirtyNotes() set({ workspaceSetupError: null }) const capabilities = window.zen.getCapabilities() @@ -9497,66 +10212,42 @@ export const useStore = create((set, get) => { }) savePrefs(collectPrefs(get())) await openVaultWorkspace(vault) - }, + }), - openLocalVault: async (root: string) => { + openLocalVault: (root: string) => { const trimmed = root.trim() - if (!trimmed) return - // Only a no-op when we are already in this exact local vault. In remote - // mode vault.root holds the server-reported path, which for a localhost - // server equals the local vault's own path -- comparing against it here - // would wrongly block switching back from remote to local. - if (get().workspaceMode === 'local' && trimmed === get().vault?.root) return - try { - await get().flushDirtyNotes() - set({ workspaceSetupError: null }) - const vault = await window.zen.openLocalVault(trimmed) - await get().refreshLocalVaults() - if (!vault) return - - const remoteWorkspaceInfo = await get().refreshWorkspaceContext() - const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) - const fresh = makeLeaf() - set({ - vault, - workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), - remoteWorkspaceInfo, - workspaceSetupError: null, - vaultSettings, - notes: [], - folders: [], - hasAssetsDir: false, - assetFiles: [], - assetUndoStack: [], - closedTabStack: [], - workflowRunRecord: null, - workflowTutorialStep: null, - vaultTasks: [], - selectedTags: [], - view: { kind: 'folder', folder: 'inbox', subpath: '' }, - selectedPath: null, - activeNote: null, - activeDirty: false, - paneLayout: fresh, - activePaneId: fresh.id, - noteContents: {}, - noteDirty: {}, - loadingNote: false, - noteBackstack: [], - noteForwardstack: [], - pendingJumpLocation: null, - pinnedRefPath: null, - workspaceRestored: false - }) - savePrefs(collectPrefs(get())) - await openVaultWorkspace(vault) - } catch (err) { - console.error('openLocalVault failed', err) - window.alert(err instanceof Error ? err.message : String(err)) - } + if (!trimmed || (get().workspaceMode === 'local' && trimmed === get().vault?.root)) + return Promise.resolve() + return runWorkspaceTransition(() => openLocalVaultImpl(trimmed)) }, - closeVault: async () => { + relocateLocalVault: (operation) => runWorkspaceTransition(async () => { + const previous = get() + if (operation.reopen && (!previous.vault || previous.workspaceMode !== 'local')) + throw new Error('Open the local vault before relocating it.') + await operation.move() + if (!operation.reopen) return + try { + await openLocalVaultImpl(operation.reopen.destination, true) + } catch (error) { + try { + await operation.rollback() + const restored = await window.zen.openLocalVault(operation.reopen.source) + if (!restored) throw new Error('The original vault could not be reopened.') + set(previous) + savePrefs(collectPrefs(previous)) + } catch (rollbackError) { + // Keep writers stopped when the native storage location is uncertain. + set({ vault: null, workspaceRestored: false, workspaceSetupError: 'Vault relocation failed. Reopen the vault after checking its storage location.' }) + throw new AggregateError([error, rollbackError], 'Vault relocation and recovery failed. Your files have not been deleted.') + } + throw error + } + }, false, true), + + closeVault: () => { + if (!get().vault || get().workspaceMode === 'remote') return Promise.resolve() + return runWorkspaceTransition(async () => { const closingVault = get().vault if (!closingVault || get().workspaceMode === 'remote') return try { @@ -9652,9 +10343,10 @@ export const useStore = create((set, get) => { console.error('closeVault failed', err) window.alert(err instanceof Error ? err.message : String(err)) } + }) }, - connectRemoteWorkspace: async () => { + connectRemoteWorkspace: () => runWorkspaceTransition(async () => { try { await get().flushDirtyNotes() const capabilities = window.zen.getCapabilities() @@ -9787,9 +10479,9 @@ export const useStore = create((set, get) => { } catch (error) { window.alert(error instanceof Error ? error.message : String(error)) } - }, + }), - connectRemoteWorkspaceProfile: async (id: string) => { + connectRemoteWorkspaceProfile: (id: string) => runWorkspaceTransition(async () => { try { await get().flushDirtyNotes() const profile = get().remoteWorkspaceProfiles.find((entry) => entry.id === id) @@ -9867,9 +10559,11 @@ export const useStore = create((set, get) => { } catch (error) { window.alert(error instanceof Error ? error.message : String(error)) } - }, + }), - changeRemoteWorkspaceVaultPath: async () => { + changeRemoteWorkspaceVaultPath: () => { + if (get().workspaceMode !== 'remote') return Promise.resolve() + return runWorkspaceTransition(async () => { try { if (get().workspaceMode !== 'remote') return const remoteInfo = get().remoteWorkspaceInfo @@ -9949,89 +10643,10 @@ export const useStore = create((set, get) => { } catch (error) { window.alert(error instanceof Error ? error.message : String(error)) } + }) }, - disconnectRemoteWorkspace: async () => { - try { - await get().flushDirtyNotes() - const vault = await window.zen.disconnectRemoteWorkspace() - const remoteWorkspaceInfo = await get().refreshWorkspaceContext() - await get().refreshLocalVaults() - - if (!vault) { - const fresh = makeLeaf() - set({ - vault: null, - workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), - remoteWorkspaceInfo, - vaultSettings: DEFAULT_VAULT_SETTINGS, - notes: [], - folders: [], - hasAssetsDir: false, - assetFiles: [], - assetUndoStack: [], - closedTabStack: [], - workflowRunRecord: null, - workflowTutorialStep: null, - vaultTasks: [], - selectedTags: [], - view: { kind: 'folder', folder: 'inbox', subpath: '' }, - selectedPath: null, - activeNote: null, - activeDirty: false, - paneLayout: fresh, - activePaneId: fresh.id, - noteContents: {}, - noteDirty: {}, - loadingNote: false, - noteBackstack: [], - noteForwardstack: [], - pendingJumpLocation: null, - pinnedRefPath: null, - workspaceRestored: true - }) - savePrefs(collectPrefs(get())) - return - } - - const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) - const fresh = makeLeaf() - set({ - vault, - workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), - remoteWorkspaceInfo, - vaultSettings, - notes: [], - folders: [], - hasAssetsDir: false, - assetFiles: [], - assetUndoStack: [], - closedTabStack: [], - workflowRunRecord: null, - workflowTutorialStep: null, - vaultTasks: [], - selectedTags: [], - view: { kind: 'folder', folder: 'inbox', subpath: '' }, - selectedPath: null, - activeNote: null, - activeDirty: false, - paneLayout: fresh, - activePaneId: fresh.id, - noteContents: {}, - noteDirty: {}, - loadingNote: false, - noteBackstack: [], - noteForwardstack: [], - pendingJumpLocation: null, - pinnedRefPath: null, - workspaceRestored: false - }) - savePrefs(collectPrefs(get())) - await openVaultWorkspace(vault) - } catch (error) { - window.alert(error instanceof Error ? error.message : String(error)) - } - }, + disconnectRemoteWorkspace: () => runWorkspaceTransition(disconnectRemoteWorkspaceImpl), saveRemoteWorkspaceProfile: async (input) => { const profile = await window.zen.saveRemoteWorkspaceProfile(input) @@ -10039,7 +10654,7 @@ export const useStore = create((set, get) => { return profile }, - deleteRemoteWorkspaceProfile: async (id) => { + deleteRemoteWorkspaceProfile: (id) => runWorkspaceTransition(async () => { const wasRemote = get().workspaceMode === 'remote' await window.zen.deleteRemoteWorkspaceProfile(id) const [profiles] = await Promise.all([ @@ -10047,9 +10662,9 @@ export const useStore = create((set, get) => { get().refreshWorkspaceContext() ]) if (wasRemote && profiles.length === 0) { - await get().disconnectRemoteWorkspace() + await disconnectRemoteWorkspaceImpl() } - }, + }), persistWorkspace: () => { const state = get() @@ -10071,6 +10686,15 @@ export const useStore = create((set, get) => { }, flushDirtyNotes: async () => { + while (commentOperations.size > 0) + await Promise.all([...commentOperations.values()].flatMap(operations => [...operations])) + while (inFlightNoteWrites.size > 0) await Promise.all([...inFlightNoteWrites]) + await Promise.all([...databaseRowActions.values()]) + await Promise.all([...folderMutations.values(), ...databaseCreations.values()]) + if ([...uncertainFolderMutations.values()].includes(get().vault)) + throw new Error('FOLDER_STATE_UNCERTAIN: Reload the vault before saving or switching') + await Promise.all([...new Set([...databaseWriteKind.keys(), ...databaseWriteQueues.keys()])] + .map((path) => flushDatabaseWrite(path, () => get().databases[path]))) get().persistWorkspace() // Before the dirty sweep, not after: a queued task write on a note someone // has open lands in the buffer rather than on disk, so draining first is @@ -10080,6 +10704,8 @@ export const useStore = create((set, get) => { .filter(([, isDirty]) => isDirty) .map(([path]) => path) await Promise.all(dirtyPaths.map(async (path) => get().persistNote(path))) + if (Object.values(get().noteDirty).some(Boolean)) + throw new Error('Notes still have unsaved changes. Save them before leaving the vault.') } } }) diff --git a/packages/app-core/src/tasks.ts b/packages/app-core/src/tasks.ts new file mode 100644 index 00000000..a4ab6f6d --- /dev/null +++ b/packages/app-core/src/tasks.ts @@ -0,0 +1,89 @@ +import { captureNavigationContext } from './lib/navigation-context' +import { useSyncExternalStore } from 'react' +import type { VaultTask } from '@bridge-contract/tasks' +import { useStore, type KanbanGroupBy } from './store' +import { filterTasksForDisplay } from '@shared/tasks' +import { computeTasksRender } from './lib/tasks-filter' +import { dropMutationsFor } from './lib/task-column-mutations' + +export type { KanbanGroupBy } from './store' +export interface TaskActionHost { isCurrent(): boolean } +export type TaskSnapshot = Readonly> & { + readonly tags: readonly string[] + readonly fields?: Readonly> +} +export interface TasksSnapshot { + readonly tasks: readonly TaskSnapshot[] + readonly loading: boolean + readonly showArchived: boolean + readonly groupBy: KanbanGroupBy +} +let source: readonly VaultTask[] | undefined +let tasks: readonly TaskSnapshot[] = Object.freeze([]) +let snapshot: TasksSnapshot | undefined + +export function getTasksSnapshot(): TasksSnapshot { + const state = useStore.getState() + if (source !== state.vaultTasks) { + source = state.vaultTasks + tasks = Object.freeze(source.map(task => Object.freeze({ ...task, + tags: Object.freeze([...task.tags]), + ...(task.fields ? { fields: Object.freeze({ ...task.fields }) } : {}) + }))) + } + if (!snapshot || snapshot.tasks !== tasks || snapshot.loading !== state.tasksLoading || snapshot.groupBy !== state.kanbanGroupBy || snapshot.showArchived !== state.showArchivedTasks) + snapshot = Object.freeze({ tasks, loading: state.tasksLoading, showArchived: state.showArchivedTasks, groupBy: state.kanbanGroupBy }) + return snapshot +} +export function subscribeTasks(listener: (next: TasksSnapshot, previous: TasksSnapshot) => void): () => void { + let previous = getTasksSnapshot() + return useStore.subscribe(() => { + const next = getTasksSnapshot() + if (next === previous) return + const before = previous; previous = next; listener(next, before) + }) +} +function subscribeReact(notify: () => void): () => void { return subscribeTasks(() => notify()) } +export function useTasksSnapshot(): TasksSnapshot { + return useSyncExternalStore(subscribeReact, getTasksSnapshot, getTasksSnapshot) +} +export function refreshTasks(path?: string): Promise { + return path ? useStore.getState().rescanTasksForPath(path) : useStore.getState().refreshTasks() +} +export async function openTask(id: string): Promise { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false + const state = useStore.getState() + const task = state.vaultTasks.find(task => task.id === id) + if (!task) return false + await state.openTaskAt(task) + return isCurrent() && useStore.getState().selectedPath === task.sourcePath +} + +/** Dispatch through the same queued task writer as desktop. Errors use core's toast UI. */ +export async function moveTaskToColumn( + host: TaskActionHost, + taskId: string, + groupBy: KanbanGroupBy, + columnId: string +): Promise { + try { if (!host.isCurrent()) return false } catch { return false } + const state = useStore.getState() + if (!state.vault || state.kanbanGroupBy !== groupBy) return false + const task = state.vaultTasks.find(task => task.id === taskId) + if (!task) return false + const mutations = dropMutationsFor(groupBy, columnId, task, new Date()) + if (!mutations) return false + if (mutations.length) await state.applyTaskMutation(task, mutations) + return true +} + +/** Today/overdue groups use core's filtering and file order, including file tasks. */ +export function getTodayTasks(now = new Date()): { readonly tasks: readonly TaskSnapshot[]; readonly overdueCount: number } { + const state = useStore.getState() + const livePaths = new Set(state.notes.filter(note => note.folder !== 'trash').map(note => note.path)) + const live = filterTasksForDisplay(state.vaultTasks, state.showArchivedTasks).filter(task => livePaths.has(task.sourcePath)) + const render = computeTasksRender(live, '', now, { today: false, upcoming: false, waiting: false, forwarded: false, done: false, cancelled: false }) + const publicTasks = new Map(getTasksSnapshot().tasks.map(task => [task.id, task])) + return Object.freeze({ tasks: Object.freeze(render.groups.today.map(task => publicTasks.get(task.id)!)), overdueCount: render.groups.overdueCount ?? 0 }) +} diff --git a/packages/app-core/src/workspace.ts b/packages/app-core/src/workspace.ts new file mode 100644 index 00000000..3982f0fb --- /dev/null +++ b/packages/app-core/src/workspace.ts @@ -0,0 +1,94 @@ +import { workspaceGeneration } from './lib/workspace-transition' +import type { LocalVaultRelocation } from './lib/workspace-relocation' +export type { LocalVaultRelocation } from './lib/workspace-relocation' +import { useSyncExternalStore } from 'react' +import type { RemoteWorkspaceProfile, RemoteWorkspaceProfileInput, WorkspaceMode } from '@bridge-contract/ipc' +import { useStore } from './store' +import { findLeaf } from './lib/pane-layout' + +export interface WorkspaceSnapshot { + readonly mode: WorkspaceMode + readonly restored: boolean + readonly transitioning: boolean + /** Changes at transition start, including transitions that cancel or fail. */ + readonly generation: number + readonly remoteProfileId: string | null + readonly remoteProfiles: readonly Readonly[] + readonly folder: { readonly kind: 'folder'; readonly folder: 'inbox' | 'quick' | 'archive' | 'trash'; readonly subpath: string } | null +} +let profilesSource: readonly RemoteWorkspaceProfile[] | undefined +let profiles: WorkspaceSnapshot['remoteProfiles'] = Object.freeze([]) +let viewSource: unknown +let folder: WorkspaceSnapshot['folder'] = null +let snapshot: WorkspaceSnapshot | undefined +export function getWorkspaceSnapshot(): WorkspaceSnapshot { + const state = useStore.getState() + if (profilesSource !== state.remoteWorkspaceProfiles) { + profilesSource = state.remoteWorkspaceProfiles + profiles = Object.freeze(profilesSource.map(profile => Object.freeze({ + id: profile.id, name: profile.name, baseUrl: profile.baseUrl, hasCredential: profile.hasCredential, + vaultPath: profile.vaultPath, lastConnectedAt: profile.lastConnectedAt + }))) + } + if (viewSource !== state.view) { + viewSource = state.view + folder = state.view.kind === 'folder' ? Object.freeze({ ...state.view }) : null + } + const next = { mode: state.workspaceMode, restored: !!state.vault && state.workspaceRestored && !state.workspaceTransitioning, + transitioning: state.workspaceTransitioning, generation: workspaceGeneration(), + remoteProfileId: state.remoteWorkspaceInfo?.profileId ?? null, remoteProfiles: profiles, folder } + if (!snapshot || (Object.keys(next) as Array).some(key => snapshot![key] !== next[key])) + snapshot = Object.freeze(next) + return snapshot +} +export function subscribeWorkspace(listener: (next: WorkspaceSnapshot, previous: WorkspaceSnapshot) => void): () => void { + let previous = getWorkspaceSnapshot() + return useStore.subscribe(() => { + const next = getWorkspaceSnapshot() + if (next === previous) return + const before = previous; previous = next; listener(next, before) + }) +} +function subscribeReact(notify: () => void): () => void { return subscribeWorkspace(() => notify()) } +export function useWorkspaceSnapshot(): WorkspaceSnapshot { return useSyncExternalStore(subscribeReact, getWorkspaceSnapshot, getWorkspaceSnapshot) } + +/** Native storage identifiers are opaque to core. The host bridge resolves them. */ +export function openLocalVault(token: string): Promise { return useStore.getState().openLocalVault(token) } +/** Reserves the whole save, native relocation, reopen and rollback lifecycle. */ +export function relocateLocalVault(operation: LocalVaultRelocation): Promise { + return useStore.getState().relocateLocalVault(operation) +} +export function pickLocalVault(): Promise { return useStore.getState().openVaultPicker() } +export function closeVault(): Promise { return useStore.getState().closeVault() } +export function connectRemoteWorkspace(): Promise { return useStore.getState().connectRemoteWorkspace() } +export function connectRemoteProfile(id: string): Promise { return useStore.getState().connectRemoteWorkspaceProfile(id) } +export function changeRemoteVaultPath(): Promise { return useStore.getState().changeRemoteWorkspaceVaultPath() } +export function disconnectRemoteWorkspace(): Promise { return useStore.getState().disconnectRemoteWorkspace() } +export function deleteRemoteProfile(id: string): Promise { return useStore.getState().deleteRemoteWorkspaceProfile(id) } +export async function refreshRemoteProfiles(): Promise { await useStore.getState().refreshRemoteWorkspaceProfiles() } +export async function saveRemoteProfile(input: RemoteWorkspaceProfileInput): Promise> { + const profile = await useStore.getState().saveRemoteWorkspaceProfile({ ...input }) + return Object.freeze({ id: profile.id, name: profile.name, baseUrl: profile.baseUrl, hasCredential: profile.hasCredential, + vaultPath: profile.vaultPath, lastConnectedAt: profile.lastConnectedAt }) +} +export function flushWorkspace(): Promise { return useStore.getState().flushDirtyNotes() } +export function persistWorkspace(): void { useStore.getState().persistWorkspace() } +export function configureWorkspacePresentation(options: { + sidebarVisible?: boolean; noteListVisible?: boolean; automaticCalendar?: boolean +}): void { + useStore.setState({ + ...(options.sidebarVisible === undefined ? {} : { sidebarOpen: options.sidebarVisible }), + ...(options.noteListVisible === undefined ? {} : { noteListOpen: options.noteListVisible }), + ...(options.automaticCalendar === undefined ? {} : { autoCalendarPanel: options.automaticCalendar }) + }) +} +/** Read before restoration rewrites the legacy persisted layout. */ +export async function readPersistedHomeState(): Promise { + try { + const raw = await window.zen.readWorkspaceState() + if (!raw) return true + const saved = JSON.parse(raw) + const leaf = findLeaf(saved.paneLayout, saved.activePaneId) + return !leaf || leaf.activeTab === null + } catch { return true } +} diff --git a/packages/bridge-contract/fixtures/README.md b/packages/bridge-contract/fixtures/README.md new file mode 100644 index 00000000..f1567d63 --- /dev/null +++ b/packages/bridge-contract/fixtures/README.md @@ -0,0 +1,45 @@ +# Vault behavior and self-hosted HTTP fixtures + +These JSON cases describe behavior shared by independent implementations. They +contain note bytes, operations, and expected results, with no runtime dependency. +The fixture schema has its own version; product versions do not change it. + +`self-hosted-http.json` defines the existing `self-hosted-http-v1` baseline for the +web bridge and TUI remote adapter: capabilities, bearer authentication, browser +session cookies, note fields, exact UTF-8 note bytes, and missing/directory errors. +Go runs it at both `/` and `/notes`. Writes retain the existing last-write-wins +behavior; this is not the revision-based Cloud save protocol. Additive fields are +compatible. Breaking route, authentication, status, or field changes need a new +protocol marker and an explicit consumer migration. + +The HTTP baseline exposed two Go gaps corrected during this migration: session +cookies now cover the configured URL prefix, and metadata includes `assetEmbeds` +as required by the bridge contract. Go invalidates old metadata caches so unchanged +notes receive the new field too. + +`task-roundtrip.json` covers due-date writes, in-progress state, fenced examples, +preserving unrelated Markdown bytes, and assigning the local calendar day near +midnight. `localNow` is `[year, month, day, hour, minute]`, with a one-based month, +interpreted in the executing host's local timezone. All `expectedAfter` fields +must match; implementations may expose additional metadata. + +TypeScript runs these through `shared-domain/src/task-roundtrip.test.ts`. Run the +timezone cases in both directions from UTC: + +```sh +TZ=America/Los_Angeles npm run test:run --workspace @zennotes/shared-domain -- task-roundtrip +TZ=Pacific/Auckland npm run test:run --workspace @zennotes/shared-domain -- task-roundtrip +``` + +The Go server (ZenNotes/znserver) runs the same cases in `internal/vault/task_roundtrip_contract_test.go`. +Its client sends the edited Markdown; the server verifies storage and parsing +before and after that write. The JSON and SHA-256 provenance are vendored in its +`testdata` directory so `go test ./...` needs no Node or sibling checkout. + +After changing a fixture, run `npm run sync:contract-fixtures -- ` +(or set `ZENNOTES_SERVER_DIR`), then the TypeScript and Go checks in both +repositories; `npm run check:contract-fixtures -- ` compares without +writing. The TUI +consumer and fixture artifact publication are still pending. Do not silently +rewrite expected results to match a divergent implementation; identify the +intended behavior first. diff --git a/packages/bridge-contract/fixtures/self-hosted-http.json b/packages/bridge-contract/fixtures/self-hosted-http.json new file mode 100644 index 00000000..d799b7b7 --- /dev/null +++ b/packages/bridge-contract/fixtures/self-hosted-http.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "protocol": "self-hosted-http-v1", + "mountPaths": [ + "", + "/notes" + ], + "note": { + "path": "inbox/Contract.md", + "body": "# Contract\n\nUnicode café 日本語. \n\n![[photo.png]]\n![](assets/document.pdf)\n", + "updatedBody": "# Contract\n\nUpdated café 日本語. \n\n![Photo]()\n\n", + "assetEmbeds": [ + "photo.png", + "assets/document.pdf" + ], + "updatedAssetEmbeds": [ + "assets/photo two.png" + ] + }, + "requiredNoteFields": [ + "path", + "title", + "folder", + "siblingOrder", + "createdAt", + "updatedAt", + "size", + "tags", + "wikilinks", + "assetEmbeds", + "hasAttachments", + "excerpt" + ], + "requiredCapabilities": [ + "version", + "platform", + "authRequired", + "supportsSessionLogin", + "browseRootsEnforced", + "supportsVaultSelection", + "supportsDirectoryBrowsing", + "supportsWatch", + "reportsMissingAsNotFound", + "supportsAssetOps", + "supportsWorkflows", + "supportsCustomTemplates" + ], + "errors": { + "unauthenticated": 401, + "challenge": "Bearer realm=\"ZenNotes\"", + "missingNote": 404, + "directoryAsNote": 400 + }, + "routePrefixes": [ + "/api", + "" + ] +} diff --git a/packages/bridge-contract/fixtures/task-roundtrip.json b/packages/bridge-contract/fixtures/task-roundtrip.json new file mode 100644 index 00000000..b3988cac --- /dev/null +++ b/packages/bridge-contract/fixtures/task-roundtrip.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "reschedule-in-progress-task-without-changing-other-content", + "note": { "path": "inbox/Release.md", "title": "Release", "folder": "inbox" }, + "body": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release due:2026-09-15 !high #release\n- [ ] Next item\n", + "taskIndex": 0, + "due": "2026-09-16", + "expectedBefore": { "due": "2026-09-15", "inProgress": true }, + "expectedBody": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release !high #release due:2026-09-16\n- [ ] Next item\n", + "expectedAfter": { "due": "2026-09-16", "inProgress": true, "checked": false, "priority": "high", "tags": ["release"] }, + "expectedTaskCount": 2 + }, + { + "id": "assign-local-today-near-midnight", + "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, + "body": "# Today\n\n- [ ] Review notes\n", + "taskIndex": 0, + "localNow": [2026, 9, 15, 0, 15], + "expectedBefore": { "checked": false }, + "expectedBody": "# Today\n\n- [ ] Review notes due:2026-09-15\n", + "expectedAfter": { "due": "2026-09-15", "checked": false }, + "expectedTaskCount": 1 + }, + { + "id": "assign-local-today-late-at-night", + "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, + "body": "# Today\n\n- [/] Review notes\n", + "taskIndex": 0, + "localNow": [2026, 9, 15, 23, 45], + "expectedBefore": { "inProgress": true }, + "expectedBody": "# Today\n\n- [/] Review notes due:2026-09-15\n", + "expectedAfter": { "due": "2026-09-15", "checked": false, "inProgress": true }, + "expectedTaskCount": 1 + } + ] +} diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index ed1abe1f..393d164d 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,19 +1,31 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.50.4", + "version": "2.51.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", "./cloud-sync": "./src/cloud-sync.ts", "./ipc": "./src/ipc.ts", "./templates": "./src/templates.ts", - "./workflows": "./src/workflows.ts" + "./workflows": "./src/workflows.ts", + "./tasks": "./src/tasks.ts", + "./databases": "./src/databases.ts", + "./mcp-clients": "./src/mcp-clients.ts", + "./custom-themes": "./src/custom-themes.ts", + "./overrides": "./src/overrides.ts", + "./application-links": "./src/application-links.ts", + "./custom-code-languages": "./src/custom-code-languages.ts", + "./app-config": "./src/app-config.ts", + "./platform": "./src/platform.ts" }, "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", "build": "tsc --noEmit -p tsconfig.json", "test": "echo 'No bridge-contract tests yet'", "test:run": "echo 'No bridge-contract tests yet'" + }, + "devDependencies": { + "typescript": "^5.7.2" } } diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts new file mode 100644 index 00000000..cc79cd96 --- /dev/null +++ b/packages/bridge-contract/src/app-config.ts @@ -0,0 +1,111 @@ +/** + * Preference keys (matching the renderer's `Prefs` shape) persisted to the + * portable config file. Keep this list in sync with `Prefs` in + * `packages/app-core/src/store.ts`; new portable settings should be added + * here AND given a TOML mapping in `apps/desktop/src/main/app-config.ts`. + */ +export const PORTABLE_PREF_KEYS = [ + // vim + 'vimMode', + 'vimInsertEscape', + 'vimYankToClipboard', + 'vimBlockImeInNormalMode', + 'vimWrappedLineMotions', + 'whichKeyHints', + 'whichKeyHintMode', + 'whichKeyHintTimeoutMs', + // keymaps (overrides only) + 'keymapOverrides', + 'ignoredKeys', + 'externalApplicationSchemes', + // search + 'vaultTextSearchBackend', + 'ripgrepBinaryPath', + 'fzfBinaryPath', + // editor + 'livePreview', + 'showHeadingLevelLabels', + 'listIndentGuides', + 'renderTablesInLivePreview', + 'completedTaskStyle', + 'mathRenderer', + 'typstTagPreambles', + 'harperEnabled', + 'harperDialect', + 'looseMathDelimiters', + 'keepViewModeAcrossNotes', + 'defaultPaneMode', + 'syncTitleHeadingOnRename', + 'markdownSnippets', + 'textReplacementsEnabled', + 'textReplacements', + 'autoPairs', + 'autoPairQuotesInProse', + 'hideBuiltinTemplates', + 'tabsEnabled', + 'wrapTabs', + 'editorFontSize', + 'mathFontScale', + 'editorLineHeight', + 'editorTabSize', + 'editorScrollOff', + 'timeFormat', + 'previewMaxWidth', + 'editorMaxWidth', + 'lineNumberMode', + 'lineNumberPosition', + 'viewSettingsScope', + 'wordWrap', + 'previewSmoothScroll', + 'pdfEmbedInEditMode', + 'pdfExportUseTheme', + // appearance + 'themeId', + 'themeFamily', + 'themeMode', + 'enabledOverrides', + 'themeTweaks', + 'darkSidebar', + 'showWindowTitleBar', + 'showSidebarChevrons', + 'contentAlign', + 'unifiedSidebar', + // typography + 'interfaceFont', + 'textFont', + 'monoFont', + // features + 'workflowsEnabled', + 'hiddenWorkflowPresets', + 'atlasEnabled', + // view + 'systemFolderLabels', + 'noteSortOrder', + 'assetSortOrder', + 'groupByKind', + 'nestedTags', + 'autoReveal', + 'quickNoteDateTitle', + 'quickNoteTitlePrefix', + 'autoCalendarPanel', + 'calendarWeekStart', + 'calendarShowWeekNumbers', + 'tasksViewMode', + 'showArchivedTasks', + 'kanbanGroupBy', + 'kanbanFolderRoot', + 'kanbanColumnTitles', + 'kanbanStatuses', + // tasks + 'savedTaskFilters' +] as const + +export type PortablePrefKey = (typeof PORTABLE_PREF_KEYS)[number] + +/** + * Transport shape for the portable config across the IPC boundary. Values are + * `unknown` on purpose , the file is user-editable plain text, so the renderer + * funnels everything through `normalizePrefs()` for validation rather than + * trusting compile-time types here. + */ +export type AppConfigPortable = Partial> diff --git a/packages/bridge-contract/src/application-links.ts b/packages/bridge-contract/src/application-links.ts new file mode 100644 index 00000000..dd881fd1 --- /dev/null +++ b/packages/bridge-contract/src/application-links.ts @@ -0,0 +1,5 @@ +export type ExternalUrlResult = { + ok: boolean + error?: 'scheme-disabled' | 'blocked' | 'open-failed' | 'desktop-only' + scheme?: string +} diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index eed9cb8f..e10b1cca 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -1,7 +1,9 @@ +import type { ZenPlatform } from './platform.js' import type { AppUpdateState, AssetMeta, CliInstallStatus, + CliInstallRequest, DeletedAsset, ExternalFileContent, ExternalFileLink, @@ -34,8 +36,8 @@ import type { VaultTextSearchCapabilities, VaultTextSearchMatch, VaultTextSearchToolPaths -} from './ipc' -import type { CustomTemplateFile, WriteTemplateInput } from './templates' +} from './ipc.js' +import type { CustomTemplateFile, WriteTemplateInput } from './templates.js' import type { CloudAccountConnectResult, CloudAccountStatus, @@ -58,7 +60,7 @@ import type { CloudSyncSettingsConflict, CloudSyncVault, CloudVaultLink -} from './cloud-sync' +} from './cloud-sync.js' import type { ApplyWorkflowInput, ExportWorkflowInput, @@ -68,29 +70,29 @@ import type { WorkflowRunSummary, WorkflowUndoResult, WriteWorkflowInput -} from './workflows' -import type { VaultTask } from '@zennotes/shared-domain/tasks' +} from './workflows.js' +import type { VaultTask } from './tasks.js' import type { DatabaseDoc, DatabaseSidecar, DatabaseSummary, DbRow -} from '@zennotes/shared-domain/databases' +} from './databases.js' import type { McpClientId, McpClientStatus, McpInstructionsPayload, McpServerRuntime -} from '@zennotes/shared-domain/mcp-clients' -import type { AppConfigPortable } from '@zennotes/shared-domain/app-config' -import type { ExternalUrlResult } from '@zennotes/shared-domain/application-links' -import type { CustomTheme } from '@zennotes/shared-domain/custom-themes' -import type { Override } from '@zennotes/shared-domain/overrides' +} from './mcp-clients.js' +import type { AppConfigPortable } from './app-config.js' +import type { ExternalUrlResult } from './application-links.js' +import type { CustomTheme } from './custom-themes.js' +import type { Override } from './overrides.js' import type { CustomCodeLanguage, CustomCodeLanguageInstallInput, CustomCodeLanguageUpdateInput -} from '@zennotes/shared-domain/custom-code-languages' +} from './custom-code-languages.js' export interface ZenCapabilities { supportsUpdater: boolean @@ -118,15 +120,17 @@ export interface ZenAppInfo { version: string description: string homepage?: string + /** Legacy renderer family. Use hostKind to distinguish native mobile shells. */ runtime: 'desktop' | 'web' + hostKind?: 'desktop' | 'browser' | 'ios' | 'android' } export interface ZenBridge { getCapabilities(): ZenCapabilities getAppInfo(): ZenAppInfo - platform(): Promise - platformSync(): NodeJS.Platform + platform(): Promise + platformSync(): ZenPlatform listSystemFonts(): Promise getAppIconDataUrl(): Promise zoomInApp(): Promise @@ -158,7 +162,7 @@ export interface ZenBridge { syncCloudVault(): Promise hasCloudVaultChanges?(): Promise /** Hosts with multiple workspace windows coordinate draft saves before sync. */ - onCloudSyncWindow?(handlers: import('./cloud-sync').CloudSyncWindowHandlers): () => void + onCloudSyncWindow?(handlers: import('./cloud-sync.js').CloudSyncWindowHandlers): () => void getCloudBootstrapConflict( conflict: CloudSyncBootstrapConflict ): Promise @@ -433,7 +437,7 @@ export interface ZenBridge { mcpGetInstructions(): Promise mcpSetInstructions(next: string | null): Promise cliGetStatus(): Promise - cliInstall(): Promise + cliInstall(request?: CliInstallRequest): Promise cliUninstall(): Promise raycastGetStatus(): Promise raycastInstall(): Promise diff --git a/packages/bridge-contract/src/custom-code-languages.ts b/packages/bridge-contract/src/custom-code-languages.ts new file mode 100644 index 00000000..e2f78b41 --- /dev/null +++ b/packages/bridge-contract/src/custom-code-languages.ts @@ -0,0 +1,31 @@ +export interface CustomCodeLanguageManifest { + schemaVersion: 1; + id: string; + name: string; + aliases: string[]; + scopeName: string; + enabled: boolean; +} + +/** Renderer-ready language record returned by the host bridge. */ +export interface CustomCodeLanguage extends CustomCodeLanguageManifest { + grammar: string; + error?: string; +} + +export interface CustomCodeLanguageInstallInput { + fileName: string; + grammar: string; + id: string; + name: string; + aliases: string[]; + enabled?: boolean; + replace?: boolean; +} + +export interface CustomCodeLanguageUpdateInput { + id: string; + name?: string; + aliases?: string[]; + enabled?: boolean; +} diff --git a/packages/bridge-contract/src/custom-themes.ts b/packages/bridge-contract/src/custom-themes.ts new file mode 100644 index 00000000..1726e21d --- /dev/null +++ b/packages/bridge-contract/src/custom-themes.ts @@ -0,0 +1,34 @@ +export type CustomThemeMode = 'light' | 'dark' + +/** Which modes a theme provides; drives the mode toggle + auto resolution. */ +export type CustomThemeModes = 'light' | 'dark' | 'both' + +/** Parsed `manifest.json`. */ +export interface ThemeManifest { + /** Display name (falls back to the slug). */ + name: string + author?: string + version?: string + description?: string + /** Modes this theme styles. Default `both`. */ + modes: CustomThemeModes + /** Optional swatch hint for the Settings card (we can't cheaply render + * arbitrary CSS into a preview). */ + preview?: { light?: string; dark?: string } +} + +/** A loaded custom theme: its manifest fields + the raw `theme.css` to inject. */ +export interface CustomTheme { + /** Stable id from the folder name, e.g. `soft-paper`. */ + slug: string + name: string + author?: string + version?: string + description?: string + modes: CustomThemeModes + /** Raw `theme.css` text, injected verbatim when this theme is active. */ + css: string + preview?: { light?: string; dark?: string } + /** Set when the folder couldn't be used; surfaced in the UI. */ + error?: string +} diff --git a/packages/bridge-contract/src/databases.ts b/packages/bridge-contract/src/databases.ts new file mode 100644 index 00000000..3c70157d --- /dev/null +++ b/packages/bridge-contract/src/databases.ts @@ -0,0 +1,149 @@ +/** + * `note` / `noteMulti` cells store `[[wikilink]]` targets , `[[A]]`, or + * `[[A]] [[B]]` space-joined for multi (bracket-delimited, so titles with + * commas survive where multiSelect's comma-joined encoding cannot). Older + * builds neither validate nor migrate unknown types: they render such cells + * as plain text and round-trip the schema untouched, which is the intended + * degradation. (#500) + */ +export type FieldType = + | 'text' + | 'number' + | 'checkbox' + | 'date' + | 'select' + | 'multiSelect' + | 'note' + | 'noteMulti' + +export interface SelectOption { + id: string + /** The literal stored in the CSV cell. */ + value: string + /** Display override; defaults to `value`. */ + label?: string + /** Palette token name (not a raw hex), mapped to a chip color by the UI. */ + color?: string +} + +/** + * Where a select / multiSelect field discovers pickable values beyond its + * hand-added options: every note, a folder subtree (vault-relative path + * prefix), or a #tag. Discovery is a picker convenience only , a picked note + * still commits as a plain option through the normal path, so boards, + * filters, and older builds see ordinary select values. Absent = manual. (#500) + */ +export type SelectOptionsSource = + | { kind: 'notes' } + | { kind: 'folder'; path: string } + | { kind: 'tag'; tag: string } + +export interface DbField { + /** Stable uuid referenced by rows/views , NOT the CSV header. */ + id: string + /** The CSV column header (display + the header text written to disk). */ + name: string + type: FieldType + /** For `select` / `multiSelect`. */ + options?: SelectOption[] + /** For `select` / `multiSelect`: auto-discover options from notes. */ + optionsSource?: SelectOptionsSource + /** Table column width in px. */ + width?: number + /** Hidden in the Table view by default (e.g. the id field). */ + hidden?: boolean +} + +export type FilterOp = + | 'is' + | 'isNot' + | 'contains' + | 'notContains' + | 'isEmpty' + | 'isNotEmpty' + | 'gt' + | 'lt' + | 'before' + | 'after' + | 'checked' + | 'unchecked' + +export interface FilterRule { + fieldId: string + op: FilterOp + value?: string +} + +/** How a view's multiple filter conditions combine. `and` = match all (the + * default, backward-compatible), `or` = match any. (#394) */ +export type FilterConjunction = 'and' | 'or' + +export interface SortRule { + fieldId: string + direction: 'asc' | 'desc' +} + +export type DbViewType = 'table' | 'board' + +export interface DbView { + id: string + name: string + type: DbViewType + filters: FilterRule[] + /** How the `filters` combine , `and` (match all, default) or `or` (match + * any). Optional so existing views keep their AND behavior. (#394) */ + filterConjunction?: FilterConjunction + sorts: SortRule[] + // --- table --- + /** Ordered fieldIds (display order). */ + columnOrder?: string[] + hiddenFieldIds?: string[] + columnWidths?: Record + // --- board --- + /** Must reference a `select` field. */ + groupByFieldId?: string + /** Order of board columns; values are SelectOption.value (+ EMPTY_GROUP). */ + boardColumnOrder?: string[] + /** Per-card visible fields. */ + cardFieldIds?: string[] +} + +/** The sidecar JSON written to `.base/schema.json`. */ +export interface DatabaseSidecar { + version: 1 + /** Field whose cells hold the row UUID (its `name` is the CSV header). */ + idFieldId: string + /** Order == on-disk CSV column order. */ + fields: DbField[] + views: DbView[] + activeViewId: string + /** Row id → vault path of that record's "page" note (created on demand). */ + pages?: Record +} + +/** Cells are raw CSV strings keyed by DbField.id. */ +export interface DbRow { + /** == cells[idFieldId]. */ + id: string + cells: Record +} + +/** Fully-hydrated database handed to the renderer (sidecar + rows + identity). */ +export interface DatabaseDoc extends DatabaseSidecar { + /** Vault-relative POSIX path of the `data.csv` , identity / cache key. */ + path: string + /** Database name: the `.base` folder name (legacy: the `.csv` basename). */ + title: string + rows: DbRow[] + /** + * Row id → whether that record's linked page note has body content (beyond + * frontmatter + the title heading). Derived on read; not persisted. + */ + pageHasContent?: Record +} + +/** Lightweight listing entry for database discovery (sidebar / quick-open). */ +export interface DatabaseSummary { + path: string + title: string +} diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 60feb2fd..2528a529 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -1,6 +1,8 @@ // Shared IPC channel names and types between main + renderer. // Keeping these in one file gives us a single source of truth. +import type { ZenPlatform } from './platform.js' + export const IPC = { WORKSPACE_GET_INFO: 'workspace:get-info', WORKSPACE_CONNECT_REMOTE: 'workspace:connect-remote', @@ -219,10 +221,27 @@ export type AppUpdatePhase = | 'not-available' | 'downloading' | 'downloaded' + | 'installing' | 'error' -/** Where on disk the `zen` shim is currently installed (or could be). */ +export interface CliInstallRequest { + /** Main-issued approval for a specific dangling historical shortcut. */ + repairToken: string +} + +/** Where on disk the `zn` shim is currently installed (or could be). */ export interface CliInstallStatus { + /** Runtime supplied by this desktop build or its retained managed install. */ + runtime?: 'go' | 'node' + runtimeVersion?: string + runtimeError?: string + /** Offered only for a missing historical app target without an ownership receipt. */ + repair?: { + token: string + oldTarget: string + newTarget: string + backupPath: string + } /** True if the wrapper script is shipped with this build. False in * dev runs where electron-vite has not bundled the CLI yet. */ available: boolean @@ -773,7 +792,7 @@ export interface LocalVaultEntry extends VaultInfo { export interface ServerCapabilities { version: string - platform: NodeJS.Platform + platform: ZenPlatform authRequired: boolean supportsSessionLogin: boolean browseRootsEnforced: boolean diff --git a/packages/bridge-contract/src/mcp-clients.ts b/packages/bridge-contract/src/mcp-clients.ts new file mode 100644 index 00000000..848ac99b --- /dev/null +++ b/packages/bridge-contract/src/mcp-clients.ts @@ -0,0 +1,48 @@ +export type McpClientId = 'claude-code' | 'claude-desktop' | 'codex' | 'opencode' + +/** Serialized state returned to the renderer for the settings UI. */ +export interface McpClientStatus { + id: McpClientId + /** Absolute path to the client's config file on this machine. */ + configPath: string + /** True if the config file currently contains a ZenNotes entry. */ + installed: boolean + /** Whether the installed entry matches what we would currently install + * (same command / args / env). False when the server path changed + * because the app moved, or when an older version installed a + * different shape. */ + upToDate: boolean + /** Human-readable diagnostic , surfaced beneath the row when the + * install state is ambiguous (file missing, permission error, etc). */ + note?: string +} + +export interface McpServerRuntime { + /** Absolute path to the Node binary that will run the server. */ + command: string + /** Arguments , typically `[mcpEntryPath]`. */ + args: string[] + /** Environment variables passed to the spawned server. */ + env: Record + /** Absolute path to the compiled MCP entry file. `null` when the + * build hasn\u2019t produced it yet (dev environment without a + * prior `npm run build`). */ + entryPath: string | null + /** Set when this build cannot run or install the MCP server at all (the + * web client). The settings page shows this sentence instead of the + * runtime details and the client list (#672). */ + unavailableReason?: string +} + +/** + * Shape returned when the renderer asks for the current server-side + * instructions. `defaultValue` is the compiled default; `current` is + * what the MCP server will actually send (either the user override + * or the default); `isCustom` flags whether an override is in place. + */ +export interface McpInstructionsPayload { + defaultValue: string + current: string + isCustom: boolean + filePath: string +} diff --git a/packages/bridge-contract/src/overrides.ts b/packages/bridge-contract/src/overrides.ts new file mode 100644 index 00000000..a01033e5 --- /dev/null +++ b/packages/bridge-contract/src/overrides.ts @@ -0,0 +1,19 @@ +/** + * CSS overrides , small user-authored `.css` files in + * `~/.config/zennotes/overrides/` that the user toggles on/off and that layer on + * top of *whichever* theme is active (built-in or custom). The enabled set is + * persisted as a portable config map (`[overrides]` in config.toml). + * + * To override a theme token from a override, target `:root[data-theme] { … }` , + * overrides are injected last, so that selector wins over both a built-in's + * `:root[data-theme="…"]` block and a custom theme's `:root {}`. + */ + +export interface Override { + /** Filename including `.css`, e.g. `punchy-accent.css`. Stable id. */ + name: string + /** Raw CSS text, injected verbatim when enabled. */ + css: string + /** Set when the file couldn't be read; surfaced in the UI. */ + error?: string +} diff --git a/packages/bridge-contract/src/platform.ts b/packages/bridge-contract/src/platform.ts new file mode 100644 index 00000000..14edd562 --- /dev/null +++ b/packages/bridge-contract/src/platform.ts @@ -0,0 +1,13 @@ +/** Operating-system identifiers returned by existing hosts, independent of Node types. */ +export type ZenPlatform = + | 'aix' + | 'android' + | 'darwin' + | 'freebsd' + | 'haiku' + | 'linux' + | 'openbsd' + | 'sunos' + | 'win32' + | 'cygwin' + | 'netbsd' diff --git a/packages/bridge-contract/src/tasks.ts b/packages/bridge-contract/src/tasks.ts new file mode 100644 index 00000000..3c3f171c --- /dev/null +++ b/packages/bridge-contract/src/tasks.ts @@ -0,0 +1,65 @@ +import type { NoteFolder } from './ipc.js' + +export type TaskPriority = 'high' | 'med' | 'low' + +export interface VaultTask { + /** Stable-ish id: `${sourcePath}#${taskIndex}`. Task index shifts only when + * tasks are added/removed above it in the same file, so this is stable + * across plain content edits. */ + id: string + /** Vault-relative POSIX path of the note containing this task. */ + sourcePath: string + /** File name without extension (for display). */ + noteTitle: string + /** Top-level vault folder the source note lives in. */ + noteFolder: NoteFolder + /** 0-based line number in the full file body (frontmatter included). */ + lineNumber: number + /** Must match `toggleTaskAtIndex` counting for round-trip edits. */ + taskIndex: number + /** Raw line as it appears on disk. */ + rawText: string + /** Display content (checkbox prefix + metadata tokens stripped). */ + content: string + checked: boolean + /** True for a `[>]` task forwarded to another note (#316). Mutually + * exclusive with `checked`; kept out of the today/upcoming/done buckets. */ + forwarded: boolean + /** True for a `[-]` task cancelled, intentionally abandoned (#450). Mutually + * exclusive with `checked`/`forwarded`; kept out of the active buckets and + * collected under its own group. */ + cancelled: boolean + /** True for a `[/]` task in progress: started, not finished (#512). Unlike + * the other non-empty state chars this one is still OPEN work, so it stays + * in Today/Upcoming, on the calendar, and on the board. It marks *how* an + * open task is going, not that it left the active set. */ + inProgress: boolean + /** ISO YYYY-MM-DD, validated via Date round-trip. */ + due?: string + /** True when `due` was *derived* from the containing daily note's date + * rather than written on the line. Lets UIs tell an implicit due apart + * from an explicit `due:` token. See `inferDailyTaskDueDates`. */ + dueInferred?: boolean + priority?: TaskPriority + /** True if `@waiting` appears anywhere on the line. */ + waiting: boolean + /** All inline `@key:value` fields on the line (lower-cased), e.g. + * `@status:review @sprint:24`. Any key can drive a Kanban group-by. Optional + * so hand-built task fixtures stay terse; the parser always sets it. (#354) */ + fields?: Record + /** Convenience accessor for `fields.status`, falling back to the note's + * `status:` frontmatter. The default Kanban custom field. (#354) */ + status?: string + /** Inline `#tags` found on the line. */ + tags: string[] + /** How this task is stored. `'file'` is a whole-note task (TaskNotes-style: + * a `.md` file tagged `#task`, metadata in frontmatter); `'inline'` (the + * default when absent) is a classic `- [ ]` checkbox line. File-tasks + * round-trip through frontmatter, not the checkbox, so mutators branch on + * this. */ + kind?: 'inline' | 'file' + /** ISO YYYY-MM-DD start/scheduled date (frontmatter `scheduled`). File-tasks. */ + scheduled?: string + /** ISO YYYY-MM-DD completion date (frontmatter `completedDate`). File-tasks. */ + completedDate?: string +} diff --git a/packages/bridge-contract/src/templates.ts b/packages/bridge-contract/src/templates.ts index 747cde83..d441b89c 100644 --- a/packages/bridge-contract/src/templates.ts +++ b/packages/bridge-contract/src/templates.ts @@ -1,7 +1,7 @@ // Shared note-template contract types. Lives in bridge-contract because both // the main process (custom-template CRUD over IPC) and the renderer (palette, // substitution) need this shape, and the IPC bridge return types reference it. -import type { NoteFolder } from './ipc' +import type { NoteFolder } from './ipc.js' export type TemplateCategory = 'Engineering' | 'Personal' | 'Custom' diff --git a/packages/bridge-contract/tsconfig.json b/packages/bridge-contract/tsconfig.json index 4106458d..b7d09dc1 100644 --- a/packages/bridge-contract/tsconfig.json +++ b/packages/bridge-contract/tsconfig.json @@ -2,6 +2,9 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, + "rootDir": "src", + "noResolve": true, + "types": [], "lib": ["ES2022", "DOM"] }, "include": ["src/**/*"] diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 704fe806..d4917406 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.50.4", + "version": "2.51.0", "type": "module", "exports": { "./*": "./src/*.ts" @@ -17,6 +17,7 @@ "lz-string": "^1.5.0" }, "devDependencies": { + "typescript": "^5.7.2", "vitest": "^3.2.6" } } diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 336f4dca..7c344104 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -1,14 +1,11 @@ -// Portable application config — the subset of user preferences that travel -// between machines via a plain-text config file (config.toml). This is the -// single source of truth for *which* preference keys are portable; both the -// renderer (to extract/apply the subset) and the desktop main process (to -// read/write the file) import from here so the two never drift. -// -// Machine-local UI state (pane widths, collapsed folders, pinned reference, -// onboarding flag, last-opened vault, window geometry) is deliberately NOT -// listed here — it stays in localStorage / the runtime config so a synced -// dotfile doesn't churn on every drag and never carries machine-specific -// layout state. +import type { PortablePrefKey, AppConfigPortable } from '@zennotes/bridge-contract/app-config' +import { PORTABLE_PREF_KEYS } from '@zennotes/bridge-contract/app-config' +export type { PortablePrefKey, AppConfigPortable } from '@zennotes/bridge-contract/app-config' +export { PORTABLE_PREF_KEYS } from '@zennotes/bridge-contract/app-config' + +// Portable preferences are defined by the bridge contract and re-exported here +// for existing consumers. This module owns normalization, defaults, and selection. +// Machine-local layout and session state remain outside portable config. /** Bumped when the on-disk config layout changes in a way that needs a * migration. Written as `config_version` at the top of the file. */ @@ -51,118 +48,6 @@ export function defaultTimeFormat(): TimeFormat { return '24h' } -/** - * Preference keys (matching the renderer's `Prefs` shape) persisted to the - * portable config file. Keep this list in sync with `Prefs` in - * `packages/app-core/src/store.ts`; new portable settings should be added - * here AND given a TOML mapping in `apps/desktop/src/main/app-config.ts`. - */ -export const PORTABLE_PREF_KEYS = [ - // vim - 'vimMode', - 'vimInsertEscape', - 'vimYankToClipboard', - 'vimBlockImeInNormalMode', - 'vimWrappedLineMotions', - 'whichKeyHints', - 'whichKeyHintMode', - 'whichKeyHintTimeoutMs', - // keymaps (overrides only) - 'keymapOverrides', - 'ignoredKeys', - 'externalApplicationSchemes', - // search - 'vaultTextSearchBackend', - 'ripgrepBinaryPath', - 'fzfBinaryPath', - // editor - 'livePreview', - 'showHeadingLevelLabels', - 'listIndentGuides', - 'renderTablesInLivePreview', - 'completedTaskStyle', - 'mathRenderer', - 'typstTagPreambles', - 'harperEnabled', - 'harperDialect', - 'looseMathDelimiters', - 'keepViewModeAcrossNotes', - 'defaultPaneMode', - 'syncTitleHeadingOnRename', - 'markdownSnippets', - 'textReplacementsEnabled', - 'textReplacements', - 'autoPairs', - 'autoPairQuotesInProse', - 'hideBuiltinTemplates', - 'tabsEnabled', - 'wrapTabs', - 'editorFontSize', - 'mathFontScale', - 'editorLineHeight', - 'editorTabSize', - 'editorScrollOff', - 'timeFormat', - 'previewMaxWidth', - 'editorMaxWidth', - 'lineNumberMode', - 'lineNumberPosition', - 'viewSettingsScope', - 'wordWrap', - 'previewSmoothScroll', - 'pdfEmbedInEditMode', - 'pdfExportUseTheme', - // appearance - 'themeId', - 'themeFamily', - 'themeMode', - 'enabledOverrides', - 'themeTweaks', - 'darkSidebar', - 'showWindowTitleBar', - 'showSidebarChevrons', - 'contentAlign', - 'unifiedSidebar', - // typography - 'interfaceFont', - 'textFont', - 'monoFont', - // features - 'workflowsEnabled', - 'hiddenWorkflowPresets', - 'atlasEnabled', - // view - 'systemFolderLabels', - 'noteSortOrder', - 'assetSortOrder', - 'groupByKind', - 'nestedTags', - 'autoReveal', - 'quickNoteDateTitle', - 'quickNoteTitlePrefix', - 'autoCalendarPanel', - 'calendarWeekStart', - 'calendarShowWeekNumbers', - 'tasksViewMode', - 'showArchivedTasks', - 'kanbanGroupBy', - 'kanbanFolderRoot', - 'kanbanColumnTitles', - 'kanbanStatuses', - // tasks - 'savedTaskFilters' -] as const - -export type PortablePrefKey = (typeof PORTABLE_PREF_KEYS)[number] - -/** - * Transport shape for the portable config across the IPC boundary. Values are - * `unknown` on purpose — the file is user-editable plain text, so the renderer - * funnels everything through `normalizePrefs()` for validation rather than - * trusting compile-time types here. - */ -export type AppConfigPortable = Partial> - const PORTABLE_KEY_SET: ReadonlySet = new Set(PORTABLE_PREF_KEYS) /** True when `key` is one of the portable preference keys. */ diff --git a/packages/shared-domain/src/application-links.ts b/packages/shared-domain/src/application-links.ts index e7ef5649..18c9d13e 100644 --- a/packages/shared-domain/src/application-links.ts +++ b/packages/shared-domain/src/application-links.ts @@ -1,3 +1,5 @@ +export type { ExternalUrlResult } from '@zennotes/bridge-contract/application-links' + /** Application links are classified before note lookup, even when disabled. */ const SCHEME_RE = /^([a-z][a-z\d+.-]*):/i const STANDARD_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']) @@ -65,9 +67,3 @@ export function classifyApplicationLink(href: string): ApplicationLink | null { url.length === scheme.length + 1 } } - -export type ExternalUrlResult = { - ok: boolean - error?: 'scheme-disabled' | 'blocked' | 'open-failed' | 'desktop-only' - scheme?: string -} diff --git a/packages/shared-domain/src/cloud-sync-host-service.test.ts b/packages/shared-domain/src/cloud-sync-host-service.test.ts index 4d9f329e..36db5e2c 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.test.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.test.ts @@ -27,7 +27,14 @@ function textContent(data: string): CloudSyncContent { function setup(vaults: CloudSyncVault[] = [], localItems: CloudSyncLocalItem[] = []) { let link: unknown = null let state: unknown = null - const persistence: CloudSyncHostPersistence = { + const retiredStates: Array<{ vaultKey: string; baseUrl: string; vaultId: string; state: unknown }> = [] + const persistence: CloudSyncHostPersistence & { + retireState(vaultKey: string, baseUrl: string, vaultId: string): Promise + } = { + async retireState(vaultKey, baseUrl, vaultId) { + if (state !== null) retiredStates.push({ vaultKey, baseUrl, vaultId, state: structuredClone(state) }) + state = null + }, async loadLink() { return link }, @@ -202,7 +209,7 @@ function setup(vaults: CloudSyncVault[] = [], localItems: CloudSyncLocalItem[] = ids: { itemId: () => 'item-1', operationId: () => 'operation-1' } }) - return { client, hostVault, persistence, service } + return { client, hostVault, persistence, service, retiredStates } } describe('CloudSyncHostService', () => { @@ -531,3 +538,88 @@ describe('CloudSyncHostService', () => { expect(client.createBackup).not.toHaveBeenCalled() }) }) + + +describe('CloudSyncHostService deleted vault recovery (#791)', () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', name: 'Notes', cursor: 0, + created_at: '2026-09-16T12:00:00.000Z', updated_at: '2026-09-16T12:00:00.000Z' + } + const missing = () => Object.assign(new Error('The requested resource was not found.'), { + name: 'CloudServiceRequestError', status: 404, code: 'NOT_FOUND' + }) + + it('retires a confirmed deleted association and its old cursor without applying local file changes', async () => { + const { service, client, persistence, hostVault } = setup([remoteVault]) + await service.link(hostVault, remoteVault.id) + await service.sync(hostVault) + expect(await persistence.loadState(hostVault.key, 'https://zennotes.org', remoteVault.id)).not.toBeNull() + const apply = vi.spyOn(hostVault.repository, 'apply') + client.changes.mockRejectedValue(missing()) + client.manifest.mockRejectedValue(missing()) + + await expect(service.sync(hostVault)).rejects.toThrow() + + expect(await service.linkedVault(hostVault)).toBeNull() + expect(await persistence.loadState(hostVault.key, 'https://zennotes.org', remoteVault.id)).toBeNull() + expect(apply).not.toHaveBeenCalled() + expect(client.deleteVault).not.toHaveBeenCalled() + expect((await service.serviceAccount()).user.email).toBe('ada@example.com') + }) + + it('archives the full conflict state including an unsent merge draft before retiring it', async () => { + const local = textContent('local version') + const cloud = textContent('cloud version') + const { service, client, persistence, hostVault, retiredStates } = setup( + [remoteVault], [{ path: 'Note.md', kind: 'text', content: local }] + ) + client.manifest.mockResolvedValue({ + data: [{ item_id: 'item-1', path: 'Note.md', kind: 'text', revision: 2, + sha256: cloud.sha256, byte_length: cloud.byte_length, media_type: cloud.media_type, content: cloud }], + cursor: 1, next_page: null + }) + await service.link(hostVault, remoteVault.id) + const conflict = (await service.sync(hostVault)).pending_conflicts![0]! + const draft = 'Unsent merge draft\r\nKeep every byte 📝\r\n' + await service.saveConflictDraft(hostVault, conflict.id, draft) + const state = structuredClone(await persistence.loadState(hostVault.key, 'https://zennotes.org', remoteVault.id)) + client.changes.mockRejectedValue(missing()) + client.manifest.mockRejectedValue(missing()) + + await expect(service.sync(hostVault)).rejects.toThrow() + + expect(retiredStates).toEqual([{ vaultKey: hostVault.key, baseUrl: 'https://zennotes.org', vaultId: remoteVault.id, state }]) + expect((retiredStates[0]!.state as CloudSyncState).pending_conflicts![conflict.id]!.draft_text).toBe(draft) + expect(await persistence.loadState(hostVault.key, 'https://zennotes.org', remoteVault.id)).toBeNull() + expect(await service.linkedVault(hostVault)).toBeNull() + }) + + it('keeps the link and active state if the recovery archive cannot be written', async () => { + const { service, client, persistence, hostVault, retiredStates } = setup([remoteVault]) + const link = await service.link(hostVault, remoteVault.id) + await service.sync(hostVault) + const state = structuredClone(await persistence.loadState(hostVault.key, 'https://zennotes.org', remoteVault.id)) + const retire = vi.spyOn(persistence, 'retireState').mockRejectedValue(new Error('Storage full')) + client.changes.mockRejectedValue(missing()) + client.manifest.mockRejectedValue(missing()) + + await expect(service.sync(hostVault)).rejects.toThrow() + + expect(retire).toHaveBeenCalledWith(hostVault.key, 'https://zennotes.org', remoteVault.id) + expect(await service.linkedVault(hostVault)).toEqual(link) + expect(await persistence.loadState(hostVault.key, 'https://zennotes.org', remoteVault.id)).toEqual(state) + expect(retiredStates).toEqual([]) + }) + + it('keeps the association when the same 404 is for a resource inside a surviving vault', async () => { + const { service, client, hostVault } = setup([remoteVault]) + const link = await service.link(hostVault, remoteVault.id) + await service.sync(hostVault) + client.changes.mockRejectedValue(missing()) + client.manifest.mockResolvedValue({ data: [], cursor: 0, next_page: null }) + + await expect(service.sync(hostVault)).rejects.toThrow() + + expect(await service.linkedVault(hostVault)).toEqual(link) + }) +}) diff --git a/packages/shared-domain/src/cloud-sync-host-service.ts b/packages/shared-domain/src/cloud-sync-host-service.ts index 1ae3b041..f610bca4 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.ts @@ -18,6 +18,7 @@ import type { import type { CloudSyncApiClient } from './cloud-sync-api' import { restoreCloudBackup } from './cloud-backup' import { normalizeCloudSyncPath } from './cloud-sync' +import { CLOUD_VAULT_REMOVED_MESSAGE, confirmCloudVaultMissing, isCloudResourceMissing, sameCloudVaultLink } from './cloud-vault-availability' import { CloudSyncCoordinator, type CloudSyncRepository, @@ -59,6 +60,8 @@ export interface CloudSyncHostPersistence { deleteLink(vaultKey: string): Promise loadState(vaultKey: string, baseUrl: string, vaultId: string): Promise saveState(vaultKey: string, baseUrl: string, state: CloudSyncState): Promise + /** Preserve drafts in inactive storage before retiring the cursor. */ + retireState(vaultKey: string, baseUrl: string, vaultId: string): Promise } export interface CloudSyncHostServiceDependencies { @@ -73,6 +76,7 @@ export interface CloudSyncHostServiceDependencies { export class CloudSyncHostService { private readonly runs = new Map>() private readonly operations = new Map>() + private readonly linkUpdates = new Map>() private readonly now: () => Date private readonly ids: CloudSyncIdSource @@ -104,7 +108,7 @@ export class CloudSyncHostService { } const link = this.linkValue(account.base_url, remoteVault) - await this.dependencies.persistence.saveLink(vault.key, link) + await this.exclusive(vault.key, () => this.dependencies.persistence.saveLink(vault.key, link), this.linkUpdates) return link } @@ -115,7 +119,7 @@ export class CloudSyncHostService { const { account, client } = await this.connection() const remoteVault = (await client.createVault(normalizedName)).data const link = this.linkValue(account.base_url, remoteVault) - await this.dependencies.persistence.saveLink(vault.key, link) + await this.exclusive(vault.key, () => this.dependencies.persistence.saveLink(vault.key, link), this.linkUpdates) return link } @@ -125,7 +129,7 @@ export class CloudSyncHostService { } async unlink(vault: CloudSyncHostVault): Promise { - await this.dependencies.persistence.deleteLink(vault.key) + await this.exclusive(vault.key, () => this.dependencies.persistence.deleteLink(vault.key), this.linkUpdates) } /** @@ -211,6 +215,37 @@ export class CloudSyncHostService { return { restore, sync } } + async hasRemoteChanges(vault: CloudSyncHostVault): Promise { + if (this.runs.has(vault.key)) return false + return this.exclusive(vault.key, async () => { + const link = await this.linkedVault(vault) + if (!link) return false + const status = await this.dependencies.accountStatus() + if (status.state !== 'connected' || status.account?.base_url !== link.base_url) return false + const state = await this.stateStore(vault.key, link.base_url, link.vault_id).load(link.vault_id) + if (!state) return true + const client = await this.dependencies.createClient() + try { + const manifest = await client.manifest(link.vault_id, { includeContent: false, perPage: 1 }) + return manifest.cursor !== state.cursor + } catch (error) { + if (isCloudResourceMissing(error) && await this.retireMissingLink(vault, link)) { + throw new Error(CLOUD_VAULT_REMOVED_MESSAGE) + } + throw error + } + }) + } + + private retireMissingLink(vault: CloudSyncHostVault, link: CloudVaultLink): Promise { + return this.exclusive(vault.key, async () => { + if (!sameCloudVaultLink(await this.linkedVault(vault), link)) return false + await this.dependencies.persistence.retireState(vault.key, link.base_url, link.vault_id) + await this.dependencies.persistence.deleteLink(vault.key) + return true + }, this.linkUpdates) + } + sync(vault: CloudSyncHostVault): Promise { const existing = this.runs.get(vault.key) if (existing) return existing @@ -251,6 +286,12 @@ export class CloudSyncHostService { pending_conflicts: result.pendingConflicts, legacy_conflict_copies: result.legacyConflictCopies } + } catch (error) { + if (await confirmCloudVaultMissing(client, link.vault_id, error) && + await this.retireMissingLink(vault, link)) { + throw new Error(CLOUD_VAULT_REMOVED_MESSAGE) + } + throw error } finally { await vault.refresh() } @@ -339,15 +380,15 @@ export class CloudSyncHostService { }) } - private exclusive(key: string, operation: () => Promise): Promise { - const previous = this.operations.get(key) + private exclusive(key: string, operation: () => Promise, operations = this.operations): Promise { + const previous = operations.get(key) let current!: Promise current = (previous ? previous.catch(() => undefined) : Promise.resolve()) .then(operation) .finally(() => { - if (this.operations.get(key) === current) this.operations.delete(key) + if (operations.get(key) === current) operations.delete(key) }) - this.operations.set(key, current) + operations.set(key, current) return current } diff --git a/packages/shared-domain/src/cloud-sync.test.ts b/packages/shared-domain/src/cloud-sync.test.ts index 69d7a97d..385cd231 100644 --- a/packages/shared-domain/src/cloud-sync.test.ts +++ b/packages/shared-domain/src/cloud-sync.test.ts @@ -74,6 +74,7 @@ describe('shouldSyncVaultPath', () => { 'assets/diagram.png', '.zennotes/vault.json', '.zennotes/comments/inbox/Note.md.comments.json', + '.zennotes/note-metadata/inbox/Note.md.metadata.json', '.zennotes/templates/meeting.md', '.zennotes/workflows/review.json' ])('includes user-authored vault file %s', (path) => { diff --git a/packages/shared-domain/src/cloud-sync.ts b/packages/shared-domain/src/cloud-sync.ts index 938b3996..b7fde7d4 100644 --- a/packages/shared-domain/src/cloud-sync.ts +++ b/packages/shared-domain/src/cloud-sync.ts @@ -81,6 +81,7 @@ export function shouldSyncVaultPath(path: string): boolean { return ( lower === '.zennotes/vault.json' || lower.startsWith('.zennotes/comments/') || + (lower.startsWith('.zennotes/note-metadata/') && lower.endsWith('.metadata.json')) || lower.startsWith('.zennotes/templates/') || lower.startsWith('.zennotes/workflows/') ) diff --git a/packages/shared-domain/src/cloud-vault-availability.ts b/packages/shared-domain/src/cloud-vault-availability.ts new file mode 100644 index 00000000..ea54bf6e --- /dev/null +++ b/packages/shared-domain/src/cloud-vault-availability.ts @@ -0,0 +1,31 @@ +import type { CloudVaultLink } from '@zennotes/bridge-contract/cloud-sync' +import type { CloudSyncApiClient } from './cloud-sync-api' + +export const CLOUD_VAULT_REMOVED_MESSAGE = + 'This Cloud vault is no longer available. This device has been unlinked; your local notes are unchanged.' + +export function isCloudResourceMissing(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const value = error as { name?: unknown; status?: unknown; code?: unknown } + return value.name === 'CloudServiceRequestError' && value.status === 404 && value.code === 'NOT_FOUND' +} + +/** Item/revision 404s and paginated vault lists cannot prove a vault is gone. */ +export async function confirmCloudVaultMissing( + client: Pick, + vaultId: string, + error: unknown +): Promise { + if (!isCloudResourceMissing(error)) return false + try { + await client.manifest(vaultId, { includeContent: false, perPage: 1 }) + return false + } catch (confirmation) { + return isCloudResourceMissing(confirmation) + } +} + +export function sameCloudVaultLink(current: CloudVaultLink | null, expected: CloudVaultLink): boolean { + return current !== null && current.base_url === expected.base_url && + current.vault_id === expected.vault_id && current.linked_at === expected.linked_at +} diff --git a/packages/shared-domain/src/custom-code-languages.ts b/packages/shared-domain/src/custom-code-languages.ts index 648352af..ad01e3fd 100644 --- a/packages/shared-domain/src/custom-code-languages.ts +++ b/packages/shared-domain/src/custom-code-languages.ts @@ -1,3 +1,11 @@ +import type { CustomCodeLanguageManifest } from '@zennotes/bridge-contract/custom-code-languages' +export type { + CustomCodeLanguageManifest, + CustomCodeLanguage, + CustomCodeLanguageInstallInput, + CustomCodeLanguageUpdateInput +} from '@zennotes/bridge-contract/custom-code-languages' + /** Shared contract and validation for user-installed TextMate code languages. */ export const CUSTOM_CODE_LANGUAGE_SCHEMA_VERSION = 1; @@ -280,38 +288,6 @@ export function isReservedCodeFenceTag(tag: string): boolean { return reservedTagSet.has(normalizeCodeFenceTag(tag)); } -export interface CustomCodeLanguageManifest { - schemaVersion: 1; - id: string; - name: string; - aliases: string[]; - scopeName: string; - enabled: boolean; -} - -/** Renderer-ready language record returned by the host bridge. */ -export interface CustomCodeLanguage extends CustomCodeLanguageManifest { - grammar: string; - error?: string; -} - -export interface CustomCodeLanguageInstallInput { - fileName: string; - grammar: string; - id: string; - name: string; - aliases: string[]; - enabled?: boolean; - replace?: boolean; -} - -export interface CustomCodeLanguageUpdateInput { - id: string; - name?: string; - aliases?: string[]; - enabled?: boolean; -} - export interface ParsedTextMateGrammar { raw: Record; name?: string; diff --git a/packages/shared-domain/src/custom-themes.ts b/packages/shared-domain/src/custom-themes.ts index 8b5250ba..2e657dc4 100644 --- a/packages/shared-domain/src/custom-themes.ts +++ b/packages/shared-domain/src/custom-themes.ts @@ -22,39 +22,18 @@ * starter + the migration), not live rendering. */ -export type CustomThemeMode = 'light' | 'dark' -/** Which modes a theme provides; drives the mode toggle + auto resolution. */ -export type CustomThemeModes = 'light' | 'dark' | 'both' - -/** Parsed `manifest.json`. */ -export interface ThemeManifest { - /** Display name (falls back to the slug). */ - name: string - author?: string - version?: string - description?: string - /** Modes this theme styles. Default `both`. */ - modes: CustomThemeModes - /** Optional swatch hint for the Settings card (we can't cheaply render - * arbitrary CSS into a preview). */ - preview?: { light?: string; dark?: string } -} - -/** A loaded custom theme: its manifest fields + the raw `theme.css` to inject. */ -export interface CustomTheme { - /** Stable id from the folder name, e.g. `soft-paper`. */ - slug: string - name: string - author?: string - version?: string - description?: string - modes: CustomThemeModes - /** Raw `theme.css` text, injected verbatim when this theme is active. */ - css: string - preview?: { light?: string; dark?: string } - /** Set when the folder couldn't be used; surfaced in the UI. */ - error?: string -} +import type { + CustomThemeMode, + CustomThemeModes, + ThemeManifest, + CustomTheme +} from '@zennotes/bridge-contract/custom-themes' +export type { + CustomThemeMode, + CustomThemeModes, + ThemeManifest, + CustomTheme +} from '@zennotes/bridge-contract/custom-themes' /** * The semantic palette of the old TOML format. Retained only as the input to diff --git a/packages/shared-domain/src/database-ops.test.ts b/packages/shared-domain/src/database-ops.test.ts index db0bd621..fba66e99 100644 --- a/packages/shared-domain/src/database-ops.test.ts +++ b/packages/shared-domain/src/database-ops.test.ts @@ -162,6 +162,30 @@ describe('createDatabaseOps', () => { expect(vault.files.has('inbox/Work/Old Name.base/data.csv')).toBe(false) }) + + it('returns the canonical directory supplied by the host after a rename', async () => { + const vault = memVault({ primaryNotesAtRoot: true }) + const rename = vault.io.renameFolder + vault.io.renameFolder = (folder, from) => rename(folder, from, 'Canonical.base') + const ops = createDatabaseOps(vault.io) + const doc = await ops.createDatabase('inbox', '', 'Original') + expect(await ops.renameDatabase(doc.path, 'Requested')).toBe('Canonical.base/data.csv') + expect(vault.files.has('Canonical.base/data.csv')).toBe(true) + }) + + + it.each(['People', 'people'])('does not overwrite a partial database folder when creating %s', async (title) => { + const vault = memVault() + vault.folders.push({ folder: 'inbox', subpath: 'People.base' }) + vault.files.set('inbox/People.base/schema.json', 'keep original schema') + vault.files.set('inbox/People.base/Record.md', 'keep record') + const created = await createDatabaseOps(vault.io).createDatabase('inbox', '', title) + expect(created.path).toBe(`inbox/${title} 2.base/data.csv`) + expect(vault.files.get('inbox/People.base/schema.json')).toBe('keep original schema') + expect(vault.files.get('inbox/People.base/Record.md')).toBe('keep record') + expect(vault.files.has('inbox/People.base/data.csv')).toBe(false) + }) + it('respects primaryNotesLocation root for inbox paths', async () => { const vault = memVault({ primaryNotesAtRoot: true }) const ops = createDatabaseOps(vault.io) @@ -218,13 +242,14 @@ describe('createDatabaseOps with remapped system folders', () => { // With archive remapped away, a directory literally named `archive/` is an // ordinary user folder inside the primary area. it('treats a literal archive/ as inbox content once archive has moved', async () => { - const vault = memVault({ systemFolderPaths: remapped }) + const vault = memVault({ primaryNotesAtRoot: true, systemFolderPaths: remapped }) const ops = createDatabaseOps(vault.io) vault.files.set('archive/Notes.base/data.csv', 'Title\nOne\n') vault.folders.push({ folder: 'inbox', subpath: 'archive/Notes.base' }) const renamed = await ops.renameDatabase('archive/Notes.base/data.csv', 'Renamed') expect(renamed).toBe('archive/Renamed.base/data.csv') + expect(vault.files.has(renamed)).toBe(true) }) }) diff --git a/packages/shared-domain/src/database-ops.ts b/packages/shared-domain/src/database-ops.ts index a1fbff9d..5f16da29 100644 --- a/packages/shared-domain/src/database-ops.ts +++ b/packages/shared-domain/src/database-ops.ts @@ -289,10 +289,11 @@ export function createDatabaseOps(io: DatabaseFileOps): DatabaseOps { const dirRel = vaultRelDir(folder, subpath, layout) const csvFor = (name: string): string => csvPathForFormDir(joinSub(dirRel, `${name}${FORM_DIR_SUFFIX}`)) - // Resolve a non-colliding .base under the directory. + // A partial database still owns its directory, even without data.csv. + const occupied = new Set((await io.listFolders()).map((entry) => vaultRelDir(entry.folder, entry.subpath, layout).toLowerCase())) let name = baseName let n = 2 - while ((await io.readFileTextOrNull(csvFor(name))) !== null) name = `${baseName} ${n++}` + while (occupied.has(formDirFromCsvPath(csvFor(name))!.toLowerCase()) || (await io.readFileTextOrNull(csvFor(name))) !== null) name = `${baseName} ${n++}` const csvPath = csvFor(name) const folderSub = joinSub(subpath, `${name}${FORM_DIR_SUFFIX}`) @@ -339,15 +340,16 @@ export function createDatabaseOps(io: DatabaseFileOps): DatabaseOps { parentRel ? `${parentRel}/${name}${FORM_DIR_SUFFIX}` : `${name}${FORM_DIR_SUFFIX}` let targetFormDir = makeFormDir(safeName) if (targetFormDir === oldFormDir) return csvPath + const layout = await io.vaultLayout() + const occupied = new Set((await io.listFolders()).map((entry) => vaultRelDir(entry.folder, entry.subpath, layout))) let n = 2 - while ((await io.readFileTextOrNull(csvPathForFormDir(targetFormDir))) !== null) { + while (occupied.has(targetFormDir) || (await io.readFileTextOrNull(csvPathForFormDir(targetFormDir))) !== null) { targetFormDir = makeFormDir(`${safeName} ${n++}`) } - const layout = await io.vaultLayout() const { folder, subpath: oldSub } = splitVaultPath(oldFormDir, layout) const { subpath: newSub } = splitVaultPath(targetFormDir, layout) - await io.renameFolder(folder, oldSub, newSub) - return csvPathForFormDir(targetFormDir) + const canonical = await io.renameFolder(folder, oldSub, newSub) + return csvPathForFormDir(vaultRelDir(folder, canonical, layout)) } async function listDatabases(): Promise { diff --git a/packages/shared-domain/src/databases.ts b/packages/shared-domain/src/databases.ts index 2c25612c..ca0b9bc6 100644 --- a/packages/shared-domain/src/databases.ts +++ b/packages/shared-domain/src/databases.ts @@ -1,3 +1,20 @@ +export type { + FieldType, + SelectOption, + SelectOptionsSource, + DbField, + FilterOp, + FilterRule, + FilterConjunction, + SortRule, + DbViewType, + DbView, + DatabaseSidecar, + DbRow, + DatabaseDoc, + DatabaseSummary +} from '@zennotes/bridge-contract/databases' + /** * CSV-backed "Databases" — a general data primitive (à la Notion / Obsidian * Bases). A `.csv` file in the vault is a database: rows are records, columns @@ -108,156 +125,6 @@ export function formTitleFromCsvPath(csvPath: string): string { return dir ? formTitleFromDir(dir) : csvPath } -/** - * `note` / `noteMulti` cells store `[[wikilink]]` targets — `[[A]]`, or - * `[[A]] [[B]]` space-joined for multi (bracket-delimited, so titles with - * commas survive where multiSelect's comma-joined encoding cannot). Older - * builds neither validate nor migrate unknown types: they render such cells - * as plain text and round-trip the schema untouched, which is the intended - * degradation. (#500) - */ -export type FieldType = - | 'text' - | 'number' - | 'checkbox' - | 'date' - | 'select' - | 'multiSelect' - | 'note' - | 'noteMulti' - -export interface SelectOption { - id: string - /** The literal stored in the CSV cell. */ - value: string - /** Display override; defaults to `value`. */ - label?: string - /** Palette token name (not a raw hex), mapped to a chip color by the UI. */ - color?: string -} - -/** - * Where a select / multiSelect field discovers pickable values beyond its - * hand-added options: every note, a folder subtree (vault-relative path - * prefix), or a #tag. Discovery is a picker convenience only — a picked note - * still commits as a plain option through the normal path, so boards, - * filters, and older builds see ordinary select values. Absent = manual. (#500) - */ -export type SelectOptionsSource = - | { kind: 'notes' } - | { kind: 'folder'; path: string } - | { kind: 'tag'; tag: string } - -export interface DbField { - /** Stable uuid referenced by rows/views — NOT the CSV header. */ - id: string - /** The CSV column header (display + the header text written to disk). */ - name: string - type: FieldType - /** For `select` / `multiSelect`. */ - options?: SelectOption[] - /** For `select` / `multiSelect`: auto-discover options from notes. */ - optionsSource?: SelectOptionsSource - /** Table column width in px. */ - width?: number - /** Hidden in the Table view by default (e.g. the id field). */ - hidden?: boolean -} - -export type FilterOp = - | 'is' - | 'isNot' - | 'contains' - | 'notContains' - | 'isEmpty' - | 'isNotEmpty' - | 'gt' - | 'lt' - | 'before' - | 'after' - | 'checked' - | 'unchecked' - -export interface FilterRule { - fieldId: string - op: FilterOp - value?: string -} - -/** How a view's multiple filter conditions combine. `and` = match all (the - * default, backward-compatible), `or` = match any. (#394) */ -export type FilterConjunction = 'and' | 'or' - -export interface SortRule { - fieldId: string - direction: 'asc' | 'desc' -} - -export type DbViewType = 'table' | 'board' - -export interface DbView { - id: string - name: string - type: DbViewType - filters: FilterRule[] - /** How the `filters` combine — `and` (match all, default) or `or` (match - * any). Optional so existing views keep their AND behavior. (#394) */ - filterConjunction?: FilterConjunction - sorts: SortRule[] - // --- table --- - /** Ordered fieldIds (display order). */ - columnOrder?: string[] - hiddenFieldIds?: string[] - columnWidths?: Record - // --- board --- - /** Must reference a `select` field. */ - groupByFieldId?: string - /** Order of board columns; values are SelectOption.value (+ EMPTY_GROUP). */ - boardColumnOrder?: string[] - /** Per-card visible fields. */ - cardFieldIds?: string[] -} - -/** The sidecar JSON written to `.base/schema.json`. */ -export interface DatabaseSidecar { - version: 1 - /** Field whose cells hold the row UUID (its `name` is the CSV header). */ - idFieldId: string - /** Order == on-disk CSV column order. */ - fields: DbField[] - views: DbView[] - activeViewId: string - /** Row id → vault path of that record's "page" note (created on demand). */ - pages?: Record -} - -/** Cells are raw CSV strings keyed by DbField.id. */ -export interface DbRow { - /** == cells[idFieldId]. */ - id: string - cells: Record -} - -/** Fully-hydrated database handed to the renderer (sidecar + rows + identity). */ -export interface DatabaseDoc extends DatabaseSidecar { - /** Vault-relative POSIX path of the `data.csv` — identity / cache key. */ - path: string - /** Database name: the `.base` folder name (legacy: the `.csv` basename). */ - title: string - rows: DbRow[] - /** - * Row id → whether that record's linked page note has body content (beyond - * frontmatter + the title heading). Derived on read; not persisted. - */ - pageHasContent?: Record -} - -/** Lightweight listing entry for database discovery (sidebar / quick-open). */ -export interface DatabaseSummary { - path: string - title: string -} - // --------------------------------------------------------------------------- // Virtual tab-path helpers (mirror lib/asset-tabs.ts). A database opens as a // virtual tab keyed by the real CSV path, so it never hits the markdown diff --git a/packages/shared-domain/src/demo-tour-data.ts b/packages/shared-domain/src/demo-tour-data.ts new file mode 100644 index 00000000..039aad83 --- /dev/null +++ b/packages/shared-domain/src/demo-tour-data.ts @@ -0,0 +1,82 @@ +export interface DemoTourTemplateFile { + path: string + body: string +} + +export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ + { + path: "inbox/demo/00 — Start Here.md", + body: "# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local files\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n" + }, + { + path: "inbox/demo/01 — Markdown Basics.md", + body: "# Markdown basics\n\nZenNotes starts with ordinary markdown. The app adds keyboard-first workflows around it, but the source stays portable and readable everywhere.\n\n## Headings\n\n```\n# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n```\n\nHeadings matter for more than styling:\n\n- they show up in the **outline**\n- they can be folded with `zc` and unfolded with `zo`\n- long notes can be searched by heading with `Space p`\n\n## Emphasis\n\n*Italic* with single asterisks, **bold** with double, ***bold italic*** with triple, `inline code` with backticks, ~~strikethrough~~ with tildes, and ==highlight== with double equals.\n\n## Paragraphs and line breaks\n\nA blank line starts a new paragraph.\nA single newline usually stays in the same paragraph.\n\nLeave two trailing spaces when you really want a hard line break. \nLike this.\n\n## Lists\n\nUnordered:\n\n- Apples\n- Bananas\n - Cavendish\n - Plantain\n- Cherries\n\nOrdered:\n\n1. Draft the note\n2. Refine the structure\n3. Ship the change\n\n## Links\n\n- External: [ZenNotes](https://lumarylabs.com)\n- Autolink: \n- Wikilink: [[07 — Wiki Links and Tags]]\n- Custom label: [[11 — Workspace, Search, and Views|workspace guide]]\n\n## Blockquotes and dividers\n\n> Markdown still does a lot with very little.\n>\n> ZenNotes just makes it faster to navigate and work with.\n\n---\n\n## Frontmatter\n\nYAML frontmatter works fine at the top of a note:\n\n```yaml\n---\ntitle: My Note\ndate: 2026-04-16\ntags: [project, research]\npriority: high\n---\n```\n\nZenNotes does not require frontmatter, but features like daily notes, tags, and task defaults can make use of it.\n\n## Slash commands\n\nZenNotes also helps you write these structures faster:\n\n- type `/` at the start of a line or after whitespace\n- choose items like headings, bullets, numbered lists, tasks, callouts, code blocks, tables, math blocks, links, images, and dividers\n- keep typing after `/` to filter the insert menu\n\nThat means markdown stays plain, but you do not have to remember every snippet from scratch.\n\n## What to try in this note\n\n- Put the cursor on a heading and fold it.\n- Switch the note between **Edit**, **Split**, and **Preview**.\n- Open the outline with `Space p`.\n- Search for this note with `Space f`.\n\n## What's next\n\nJump to [[02 — Code Blocks]] for syntax highlighting, [[06 — Callouts and Footnotes]] for richer block styles, or back to [[00 — Start Here]].\n\n#demo #markdown\n" + }, + { + path: "inbox/demo/02 — Code Blocks.md", + body: "# Code blocks\n\nZenNotes treats code fences as plain markdown on disk and renders them with syntax highlighting in preview and split view.\n\n## A fast way to insert them\n\nType `/` and choose **Code block** if you do not want to type the fence manually.\n\n## TypeScript\n\n```ts\nexport interface User {\n id: string\n name: string\n roles: string[]\n}\n\nexport async function fetchUser(id: string): Promise {\n const response = await fetch(`/api/users/${id}`)\n if (!response.ok) return null\n return (await response.json()) as User\n}\n```\n\n## Python\n\n```python\nfrom dataclasses import dataclass\n\n@dataclass\nclass Point:\n x: float\n y: float\n\n def distance_to(self, other: \"Point\") -> float:\n return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5\n```\n\n## Bash\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nfor note in inbox/*.md; do\n words=$(wc -w < \"$note\")\n printf \"%6d %s\\n\" \"$words\" \"$(basename \"$note\")\"\ndone\n```\n\n## Rust\n\n```rust\nuse std::collections::HashMap;\n\nfn word_count(text: &str) -> HashMap {\n let mut counts = HashMap::new();\n for word in text.split_whitespace() {\n *counts.entry(word.to_lowercase()).or_insert(0) += 1;\n }\n counts\n}\n```\n\n## JSON\n\n```json\n{\n \"name\": \"ZenNotes\",\n \"productName\": \"ZenNotes\",\n \"version\": \"0.1.0\",\n \"scripts\": {\n \"dev\": \"electron-vite dev\",\n \"build\": \"electron-vite build\"\n }\n}\n```\n\n## Diff\n\n```diff\n- Space /\n+ Space s t\n```\n\n## Plain text\n\n```\nNo language tag, no syntax highlighting.\nUseful for raw config examples or ASCII notes.\n```\n\n## Inline code\n\nUse `inline code` when the snippet belongs inside a sentence.\n\n## Workflow notes\n\n- **Edit** mode is best for writing or refactoring the raw fence.\n- **Split** mode is ideal when you want source on one side and highlighted output on the other.\n- Fenced blocks are ignored by the task scanner, so `- [ ]` inside code stays an example, not a live task.\n- Vault text search can still find matching text inside code fences because they are part of the note body.\n\n## What's next\n\nSee [[05 — Mermaid Diagrams]] for Mermaid fences, [[05b — Math Diagrams]] for TikZ, JSXGraph, and function-plot, or [[10 — Ideas and Tasks]] for how snippets mix with prose and planning in a real note.\n\n#demo #code\n" + }, + { + path: "inbox/demo/03 — Tables and Task Lists.md", + body: "# Tables and task lists\n\n## Tables\n\nPlain GFM tables. Alignment is controlled with colons in the divider row.\n\n| Feature | Support | Notes |\n| ---------- | :--------: | --------------------------------------------------------- |\n| Headings | ✅ | Fold from the editor gutter and jump via the outline. |\n| Wiki links | ✅ | `[[Title]]` resolves by note name. |\n| Tags | ✅ | Written inline as `#like-this`. |\n| Math | ✅ | KaTeX, inline and display. |\n| Mermaid | ✅ | Rendered inside preview and split view. |\n| Search | ✅ | Notes by title/path, vault text by fuzzy content search. |\n| Sync | File-based | Use any sync tool that watches folders. |\n\nRight-aligned numbers:\n\n| Quarter | Revenue | Delta |\n| ------: | -------: | -----: |\n| Q1 | $124,300 | +4.2% |\n| Q2 | $131,980 | +6.2% |\n| Q3 | $129,010 | −2.3% |\n| Q4 | $152,407 | +18.1% |\n\n## Task lists\n\nEvery checkbox survives on disk as normal markdown like `- [ ]` and `- [x]`.\n\n## What ZenNotes task parsing supports\n\n### Core checkboxes\n\n- [ ] Open task\n- [x] Completed task\n- [X] Uppercase `X` also counts as completed\n\n### Different list styles still count\n\n- [ ] Bulleted task using `-`\n+ [ ] Bulleted task using `+`\n* [ ] Bulleted task using `*`\n1. [ ] Ordered task using `1.`\n2) [ ] Ordered task using `2)`\n> - [ ] Blockquoted task lines are parsed too\n\n### Nested tasks\n\n- [ ] Weekly review\n - [ ] Clear inbox to zero\n - [ ] Triage [[10 — Ideas and Tasks]]\n - [x] Back up vault\n - [ ] Plan next week\n - [ ] Monday — design review\n - [ ] Tuesday — code-freeze prep\n - [x] Saturday — offline\n\n### Metadata tokens on the task line\n\n- [ ] Ship the onboarding checklist due:2026-04-18 !high #onboarding #docs\n- [ ] Refresh demo screenshots due:2026-04-22 !med #demo #assets\n- [ ] Clean up seed notes !low #maintenance\n- [ ] Wait for design sign-off @waiting #design\n- [ ] Review vault search UX due:2026-04-30 !high #search #ux\n\nThe parser understands these tokens:\n\n| Token | Meaning | Example |\n| ----- | ------- | ------- |\n| `due:YYYY-MM-DD` | ISO due date used for grouping | `due:2026-04-22` |\n| `!high` / `!med` / `!low` | Priority marker | `!high` |\n| `@waiting` | Moves the task into the Waiting group | `@waiting` |\n| `#tag` | Inline task tag, searchable in the Tasks view | `#design` |\n\n### What the Tasks view does with them\n\n- Tasks with no due date land in **Today**\n- Tasks due today or already overdue also land in **Today**\n- Tasks due in the future land in **Upcoming**\n- Tasks with `@waiting` land in **Waiting**\n- Checked tasks land in **Done**\n- Overdue tasks contribute to the overdue count in the **Today** section\n\n### Filtering and navigation\n\nPress the sidebar **Tasks** row to scan every live note across **Inbox**, **Quick Notes**, and **Archive**. From there you can:\n\n- filter by task content\n- filter by note title\n- filter by inline `#tags`\n- filter by priority markers like `!high`\n- press `Enter` or `o` to open the source note\n- press `Space` or `x` to toggle the selected task without leaving the list\n\n### Ignored on purpose\n\nTasks inside fenced code blocks are not parsed, so you can document task syntax safely:\n\n```md\n- [ ] This looks like a task\n- [x] But code fences are ignored by the vault-wide task scanner\n- [ ] That makes examples and snippets safe\n```\n\n### Note-level defaults\n\nYou can also set due date and priority defaults in frontmatter, then override them inline per task:\n\n```yaml\n---\ndue: 2026-05-01\npriority: high\n---\n```\n\nWith defaults like that, a plain line such as `- [ ] Draft roadmap` inherits the due date and priority even without repeating the tokens.\n\n### Rendering checklist\n\nEvery item below is wired up:\n\n- [x] Paragraphs\n- [x] Emphasis: _italic_, **bold**, ~~strike~~\n- [x] Ordered and unordered lists\n- [x] Tables\n- [x] Task lists\n- [x] Blockquotes\n- [x] Footnotes (see [[06 — Callouts and Footnotes]])\n- [x] Math blocks (see [[04 — Math with KaTeX]])\n- [x] Mermaid (see [[05 — Mermaid Diagrams]])\n- [x] TikZ, JSXGraph, and function-plot (see [[05b — Math Diagrams]])\n- [x] Vault-wide Tasks grouping and filtering\n- [ ] Screenshots in the tour due:2026-04-25 !med #docs\n\n## Tasks as an app feature\n\nThe Tasks tab is not just a renderer demo. It is a vault-wide operational view for planning and review. Use it when you want one place to see what is due, what is waiting, what is done, and where each task lives.\n\n#demo #tasks #tables\n" + }, + { + path: "inbox/demo/04 — Math with KaTeX.md", + body: "# Math with KaTeX\n\nZenNotes renders LaTeX math via KaTeX. The source stays plain markdown while preview and split mode give you readable math output.\n\n## A fast way to insert math\n\nType `/` and choose **Math block** when you want display math without typing the fence from memory.\n\n## Inline math\n\nEuler's identity is $e^{i\\pi} + 1 = 0$. \nThe area of a circle is $A = \\pi r^2$. \nA quadratic has roots $x = \\dfrac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$.\n\n## Display blocks\n\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\, dx = \\sqrt{\\pi}\n$$\n\n$$\n\\frac{\\partial}{\\partial t} \\Psi(x, t) = -\\frac{\\hbar^2}{2m} \\frac{\\partial^2}{\\partial x^2} \\Psi(x, t) + V(x)\\Psi(x, t)\n$$\n\n## Aligned equations\n\n$$\n\\begin{aligned}\n(a + b)^2 &= a^2 + 2ab + b^2 \\\\\n(a - b)^2 &= a^2 - 2ab + b^2 \\\\\na^2 - b^2 &= (a + b)(a - b)\n\\end{aligned}\n$$\n\n## Matrices\n\n$$\n\\mathbf{A} =\n\\begin{bmatrix}\n 1 & 2 & 3 \\\\\n 4 & 5 & 6 \\\\\n 7 & 8 & 9\n\\end{bmatrix}\n\\qquad\n\\det(\\mathbf{A}) = 0\n$$\n\n## Summations, limits, derivatives\n\n$$\n\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}\n\\qquad\n\\lim_{x \\to 0} \\frac{\\sin x}{x} = 1\n\\qquad\n\\frac{d}{dx} \\ln x = \\frac{1}{x}\n$$\n\n## Probability and finance\n\n$$\nP(A \\mid B) = \\frac{P(B \\mid A) P(A)}{P(B)}\n$$\n\n$$\nC = S_0 \\Phi(d_1) - K e^{-rT} \\Phi(d_2)\n$$\n\n$$\nd_1 = \\frac{\\ln(S_0 / K) + (r + \\tfrac{1}{2}\\sigma^2) T}{\\sigma \\sqrt{T}}, \\qquad d_2 = d_1 - \\sigma \\sqrt{T}\n$$\n\n## Why this matters in ZenNotes\n\n- **Edit** mode keeps the raw LaTeX visible.\n- **Split** mode is great when you want source and rendered math side by side.\n- **Preview** mode turns math-heavy notes into something closer to a paper or spec.\n- Vault text search still sees the underlying source, which makes formulas searchable as text.\n\n## Prefer Typst? An alternative math engine\n\nZenNotes can also typeset math with **Typst** instead of KaTeX. Open **Settings ▸ Editor ▸ Math renderer** and pick **Typst**; it applies in both the live editor and the reading view.\n\nTypst reads the same `$…$` and `$$…$$` blocks as **Typst markup**, not LaTeX, so each note's math is written for whichever engine you pick. The formulas here are Typst syntax: with the Math renderer set to **Typst** they render; with **KaTeX** (the default) they show as errors until you switch.\n\nInline: $x^2 + y^2 = z^2$ and $sqrt(a^2 + b^2)$.\n\n$$\nintegral_0^1 x^2 dif x = 1/3\n$$\n\n$$\nsum_(n=1)^oo 1/n^2 = pi^2/6\n$$\n\n$$\nmat(1, 2; 3, 4) quad vec(a, b, c)\n$$\n\n## What's next\n\nWhen the note needs geometry, plotted functions, or figure-quality diagrams rather than equation layout, jump to [[05b — Math Diagrams]].\n\n#demo #math #reference\n" + }, + { + path: "inbox/demo/05 — Mermaid Diagrams.md", + body: "# Mermaid diagrams\n\nMermaid fences render inline in ZenNotes. They are still just markdown code blocks on disk, so you can version them, diff them, and edit them anywhere.\n\nFor TikZ, JSXGraph, and function-plot, see [[05b — Math Diagrams]].\n\n## A fast way to insert one\n\nType `/` and choose **Code block**, then change the language to `mermaid`.\n\n## Flowchart\n\n```mermaid\nflowchart LR\n A([User types]) --> B{Vim mode?}\n B -- yes --> C[CodeMirror vim keymap]\n B -- no --> D[Standard editing]\n C --> E[Save to .md]\n D --> E\n E --> F([File on disk])\n```\n\n## Sequence diagram\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant R as Renderer\n participant M as Main process\n participant D as Disk\n\n U->>R: Type in editor\n R->>M: writeNote(path, body)\n M->>D: fs.writeFile(...)\n D-->>M: ok\n M-->>R: NoteMeta\n R-->>U: Clean tab title\n```\n\n## State diagram\n\n```mermaid\nstateDiagram-v2\n [*] --> Draft\n Draft --> Review : Submit\n Review --> Draft : Request changes\n Review --> Approved : Accept\n Approved --> Published : Ship\n Published --> Archived : 90 days\n Archived --> [*]\n```\n\n## Gantt chart\n\n```mermaid\ngantt\n title Product roadmap\n dateFormat YYYY-MM-DD\n axisFormat %b %d\n\n section Editor\n Vim motions polish :done, vim1, 2026-03-10, 5d\n Outline panel :done, out1, 2026-03-17, 3d\n Attachments preview :active, att1, 2026-04-15, 7d\n Multi-window sync : mws1, after att1, 5d\n\n section Release\n QA pass : qa1, after mws1, 3d\n Ship :milestone, rel1, after qa1, 0d\n```\n\n## Pie chart\n\n```mermaid\npie title How the day was spent\n \"Deep work\" : 45\n \"Meetings\" : 15\n \"Slack\" : 10\n \"Reading\" : 20\n \"Breaks\" : 10\n```\n\n## Vault map\n\n```mermaid\ngraph TB\n subgraph Lifecycle\n Q[Quick Notes]\n I[Inbox]\n A[Archive]\n T[Trash]\n end\n Q --> I\n I --> A\n I --> T\n A --> I\n T --> I\n```\n\n## Working with diagrams in the app\n\n- **Split** mode is usually the sweet spot: raw source on one side, rendered diagram on the other.\n- Diagrams are still searchable because the source fence lives in the note body.\n- If Mermaid syntax breaks, ZenNotes falls back to showing the source block, which makes failures debuggable instead of mysterious.\n\n## What's next\n\nStay in diagram mode with [[05b — Math Diagrams]] if you want interactive geometry, coordinate figures, or compact function plots.\n\n#demo #mermaid #diagrams\n" + }, + { + path: "inbox/demo/05b — Math Diagrams.md", + body: "# Math diagrams — TikZ, JSXGraph, and function-plot\n\nBeyond Mermaid (see [[05 — Mermaid Diagrams]]) and KaTeX (see [[04 — Math with KaTeX]]), ZenNotes renders three more diagram types from plain fenced code blocks. Each one shines at a different job.\n\nSwitch to **Preview** or **Split** mode to see them rendered. The source stays plain markdown on disk.\n\n---\n\n## TikZ — figure-quality math diagrams\n\nUse when you want paper-grade vector figures: coordinate systems, geometry, commutative diagrams, automata, trees, plots. The full TikZ + pgfplots toolchain compiles on-device via WebAssembly — no network, no LaTeX install.\n\n### A parabola with axes\n\n```tikz\n\\begin{tikzpicture}\n \\draw[->, thick] (-2.2,0) -- (2.2,0) node[right] {$x$};\n \\draw[->, thick] (0,-0.5) -- (0,4.5) node[above] {$y$};\n \\draw[domain=-2:2, smooth, thick, blue] plot (\\x,{\\x*\\x});\n \\node[blue, above right] at (1.4, 1.96) {$y = x^2$};\n\\end{tikzpicture}\n```\n\n### A triangle with labelled vertices\n\n```tikz\n\\begin{tikzpicture}\n \\coordinate[label=below left:$A$] (A) at (0,0);\n \\coordinate[label=below right:$B$] (B) at (4,0);\n \\coordinate[label=above:$C$] (C) at (1.5,3);\n \\draw[thick] (A) -- (B) -- (C) -- cycle;\n \\draw[dashed] (C) -- ($ (A)!(C)!(B) $) node[pos=0.5, right] {$h$};\n\\end{tikzpicture}\n```\n\n### A small commutative diagram\n\n```tikz\n\\begin{tikzpicture}[node distance=2.2cm, every node/.style={font=\\small}]\n \\node (A) {$A$};\n \\node (B) [right of=A] {$B$};\n \\node (C) [below of=A] {$C$};\n \\node (D) [right of=C] {$D$};\n \\draw[->] (A) -- node[above] {$f$} (B);\n \\draw[->] (A) -- node[left] {$g$} (C);\n \\draw[->] (B) -- node[right] {$h$} (D);\n \\draw[->] (C) -- node[below] {$k$} (D);\n\\end{tikzpicture}\n```\n\n---\n\n## JSXGraph — interactive geometry and plots\n\nUse when you want the diagram to be **draggable** and **live**. Points move, sliders animate, curves reflow. Configuration is a small JSON object — no JavaScript required.\n\nEach object takes a `type` (the JSXGraph element name) and `args` (the element's constructor arguments). Assign an `id` to reference an object from a later one using `\"@id\"` — useful for attaching points to curves, for example.\n\n### Sine wave with a point on the curve\n\nJSXGraph's `functiongraph` evaluates string expressions with its built-in **JessieCode** parser — so write `sin(x)`, `cos(x)`, `x^2`, `exp(x)`, etc. directly (no `Math.` prefix).\n\n```jsxgraph\n{\n \"boundingbox\": [-6.5, 1.6, 6.5, -1.6],\n \"axis\": true,\n \"objects\": [\n {\n \"id\": \"curve\",\n \"type\": \"functiongraph\",\n \"args\": [\"sin(x)\"],\n \"attributes\": { \"strokeColor\": \"#6caedf\", \"strokeWidth\": 2 }\n },\n {\n \"type\": \"glider\",\n \"args\": [1, 0, \"@curve\"],\n \"attributes\": {\n \"name\": \"P\",\n \"size\": 4,\n \"strokeColor\": \"#d35e0c\",\n \"fillColor\": \"#d35e0c\"\n }\n }\n ]\n}\n```\n\nDrag `P` along the curve.\n\n### Unit circle with a labelled point\n\n```jsxgraph\n{\n \"boundingbox\": [-1.6, 1.6, 1.6, -1.6],\n \"axis\": true,\n \"width\": 360,\n \"height\": 360,\n \"objects\": [\n {\n \"type\": \"circle\",\n \"args\": [[0, 0], 1],\n \"attributes\": { \"strokeColor\": \"#945e80\" }\n },\n {\n \"type\": \"point\",\n \"args\": [0.7, 0.7141],\n \"attributes\": {\n \"name\": \"Q\",\n \"fillColor\": \"#6c782e\",\n \"strokeColor\": \"#6c782e\"\n }\n }\n ]\n}\n```\n\n### Two lines and their intersection\n\n```jsxgraph\n{\n \"boundingbox\": [-5, 5, 5, -5],\n \"axis\": true,\n \"objects\": [\n { \"id\": \"A\", \"type\": \"point\", \"args\": [-3, -2], \"attributes\": { \"name\": \"A\" } },\n { \"id\": \"B\", \"type\": \"point\", \"args\": [ 3, 2], \"attributes\": { \"name\": \"B\" } },\n { \"id\": \"C\", \"type\": \"point\", \"args\": [-3, 2], \"attributes\": { \"name\": \"C\" } },\n { \"id\": \"D\", \"type\": \"point\", \"args\": [ 3, -2], \"attributes\": { \"name\": \"D\" } },\n {\n \"id\": \"L1\",\n \"type\": \"line\",\n \"args\": [\"@A\", \"@B\"],\n \"attributes\": { \"strokeColor\": \"#45707a\" }\n },\n {\n \"id\": \"L2\",\n \"type\": \"line\",\n \"args\": [\"@C\", \"@D\"],\n \"attributes\": { \"strokeColor\": \"#c14a4a\" }\n },\n {\n \"type\": \"intersection\",\n \"args\": [\"@L1\", \"@L2\", 0],\n \"attributes\": { \"name\": \"X\", \"size\": 4, \"fillColor\": \"#b47109\" }\n }\n ]\n}\n```\n\nDrag any of `A`–`D` and the intersection follows.\n\n---\n\n## function-plot — quick Cartesian plots\n\nSmallest and simplest of the three. Give it functions, get a plot. Great for calculus-style notes and quick sanity checks.\n\nThe fence body is the options object passed to [function-plot](https://mauriciopoppe.github.io/function-plot/). Expression syntax is standard JavaScript math — `Math.PI`, `Math.sin(x)`, etc. — plus the `x^2` shorthand for powers.\n\n### Several functions on one axis\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"xAxis\": { \"domain\": [-6.28, 6.28] },\n \"grid\": true,\n \"data\": [\n { \"fn\": \"sin(x)\", \"color\": \"#45707a\" },\n { \"fn\": \"cos(x)\", \"color\": \"#c14a4a\" },\n { \"fn\": \"x / 3.14159265\", \"color\": \"#6c782e\" }\n ]\n}\n```\n\n### A derivative annotation\n\nHover the curve — the tangent slope updates live.\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-2, 8] },\n \"xAxis\": { \"domain\": [-3, 3] },\n \"grid\": true,\n \"data\": [\n {\n \"fn\": \"x^2\",\n \"derivative\": { \"fn\": \"2 * x\", \"updateOnMouseMove\": true },\n \"color\": \"#945e80\"\n }\n ]\n}\n```\n\n### A parametric curve\n\n```function-plot\n{\n \"xAxis\": { \"domain\": [-1.5, 1.5] },\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"grid\": true,\n \"data\": [\n {\n \"graphType\": \"polyline\",\n \"fnType\": \"parametric\",\n \"x\": \"cos(t)\",\n \"y\": \"sin(t)\",\n \"range\": [0, 6.283],\n \"color\": \"#b47109\"\n }\n ]\n}\n```\n\n---\n\n## When to reach for which\n\n| You want… | Use |\n| ---------------------------------------------------------------- | ------------------------------------------- |\n| Paper-grade static figure, TikZ muscle-memory, LaTeX portability | **TikZ** |\n| Interactive geometry, draggable points, geometry theorems | **JSXGraph** |\n| Quick plot of a few functions, minimal config | **function-plot** |\n| Flow / sequence / state / gantt / ER diagram | **Mermaid** (see [[05 — Mermaid Diagrams]]) |\n| Inline formulas, display equations | **KaTeX** (see [[04 — Math with KaTeX]]) |\n\n#demo #math #diagrams #tikz #jsxgraph #function-plot\n" + }, + { + path: "inbox/demo/06 — Callouts and Footnotes.md", + body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" + }, + { + path: "inbox/demo/07 — Wiki Links and Tags.md", + body: "# Wiki links, tags, backlinks, and search\n\nThese features turn a folder of markdown files into a navigable vault.\n\n## Wiki links\n\nPoint at other notes with `[[double brackets]]`. ZenNotes resolves them by note title, case-insensitively.\n\n- Shortest form: [[01 — Markdown Basics]]\n- Custom display text: [[11 — Workspace, Search, and Views|workspace guide]]\n- Missing note: [[A Future Note]] — opening it offers to create the note\n\nYou can follow links with the mouse or keyboard:\n\n- in Vim mode, put the cursor on a link and press `gd`\n- markdown links and wikilinks both work\n- PDFs can open directly into the reference pane\n\n## Tags\n\nTags are plain inline text. They start with `#` and become searchable structure.\n\nThis demo folder uses tags like:\n\n- #demo\n- #reference\n- #tasks\n- #vim\n- #search\n- #workspace\n\nThe **Tags** view lets you browse notes matching one or more selected tags in a dedicated main-pane list.\n\n## Connections\n\nThe **Connections** panel helps you inspect:\n\n- outbound links from the current note\n- backlinks into the current note\n- unresolved link targets that still need a note\n\nThis is especially useful when you are writing specs, research notes, or project docs and want context without leaving the active note.\n\n## Search modes\n\nZenNotes has two distinct searches:\n\n### Note search\n\n- `⌘P` opens the note search palette\n- `Space f` opens the same search in Vim mode\n- this search matches note titles and paths\n\n### Vault text search\n\n- `Space s t` opens vault text search\n- it searches matching text lines across **Inbox**, **Quick Notes**, and **Archive**\n- selecting a result opens the note and jumps to the matched line\n\nVault text search can run on different backends:\n\n- **Auto** prefers `fzf`, then `ripgrep`, then built-in\n- **Built-in** keeps everything inside ZenNotes\n- **ripgrep** and **fzf** can be chosen explicitly\n- custom binary paths can be configured in **Settings**\n- the app shows the resolved runtime backend so you can see what is actually being used\n\n## Graph of this tour\n\n```mermaid\ngraph LR\n A[[00 — Start Here]]\n A --> B[[01 — Markdown Basics]]\n A --> C[[02 — Code Blocks]]\n A --> D[[03 — Tables and Task Lists]]\n A --> E[[04 — Math with KaTeX]]\n A --> F[[05 — Mermaid Diagrams]]\n A --> G[[05b — Math Diagrams]]\n A --> H[[06 — Callouts and Footnotes]]\n A --> I[[07 — Wiki Links and Tags]]\n A --> J[[08 — Daily Notes]]\n A --> K[[09 — Vim Cheat Sheet]]\n A --> L[[10 — Ideas and Tasks]]\n A --> M[[11 — Workspace, Search, and Views]]\n A --> N[[12 — Settings and Keymaps]]\n A --> O[[13 — Commands, Help, and Demo Tour]]\n A --> P[[14 — Reference Pane and Floating Windows]]\n A --> Q[[15 — Search Backends and Fuzzy Workflows]]\n```\n\n#demo #reference #search #links\n" + }, + { + path: "inbox/demo/08 — Daily Notes.md", + body: "---\ntitle: 2026-04-16\ndate: 2026-04-16\ntags: [daily, log, demo]\n---\n\n# Thursday, 2026-04-16\n\n> [!tip] Pattern\n> A daily note is still just a `.md` file. Keep it under `inbox/daily/`, `quick/`, or wherever your vault makes sense. If you name it `YYYY-MM-DD.md`, it sorts chronologically without extra tooling.\n\n## Why daily notes fit ZenNotes well\n\n- they stay file-based and sync-friendly\n- they pair naturally with quick capture\n- they work well with tasks, tags, and links\n- reopening the app restores your tabs, panes, and window bounds, so an active daily workflow is easy to resume\n\n## Agenda\n\n- [ ] Morning: triage [[10 — Ideas and Tasks]]\n- [ ] 10:00 — design review\n- [ ] 12:00 — lunch\n- [x] 14:00 — code-freeze prep\n- [ ] Evening: reading — Seeing Like a State, chapter 3\n\n## Quick capture and dates\n\nQuick Notes are for fast capture. From there you can:\n\n- keep the note in Quick Notes\n- move it into Inbox\n- archive it later\n- trash it with confirmation if it is no longer useful\n\nDate helpers are also built in:\n\n- type `@` to insert **Today**, **Yesterday**, or **Tomorrow**\n- the inserted value is an ISO date like `2026-04-16`\n- ISO dates stay readable, sortable, and easy to search\n\nExamples:\n\n- Review due @today\n- Follow up on search backend docs @tomorrow\n- Closed the previous thread @yesterday\n\n## Log\n\n- Shipped the vault text search backend picker.\n- Updated the demo vault so it covers the current product surface.\n- Verified that session restore brings back the working layout after relaunch.\n\n## Wins\n\n- The same note works in edit, split, or preview mode.\n- Tasks here show up in the vault-wide Tasks view.\n- Links here also show up in Connections.\n\n## Follow-ups\n\n- [ ] Add a sample PDF so the reference-pane flow is demonstrated with a real file.\n- [ ] Add more screenshots for the search palette.\n- [ ] Refine the help text for view-specific ex prompts.\n\n## Notes for tomorrow\n\n- [ ] Carry over open tasks from [[03 — Tables and Task Lists]]\n- [ ] Review [[12 — Settings and Keymaps]] for any missing personalization features\n\n#daily #log #demo\n" + }, + { + path: "inbox/demo/09 — Vim Cheat Sheet.md", + body: "# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading at the cursor |\n| `zo` | Unfold the heading at the cursor |\n| `zM` | Fold all headings |\n| `zR` | Unfold all headings |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 — Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n" + }, + { + path: "inbox/demo/10 — Ideas and Tasks.md", + body: "# Ideas and tasks — a realistic note\n\nThis is the kind of note most real users end up writing: prose, todos, links, snippets, diagrams, and operational context all mixed together. It shows how ZenNotes features compose instead of living in isolated demos.\n\n> [!note]\n> Status as of 2026-04-16. Use this note to test search, outline, connections, Tasks, and split view in one place.\n\n## Open questions\n\n- [ ] Should attachment previews appear inline for PDFs by default?\n- [ ] Is the built-in text-search backend fast enough on large vaults when neither `fzf` nor `ripgrep` is available?\n- [ ] Do we expose tag renaming from the UI, or keep it intentionally file-grep first?\n\n## Working notes\n\n- Quick capture starts in **Quick Notes**, but anything important should graduate into **Inbox**.\n- Cold notes belong in **Archive**, which now opens as a dedicated main-pane list view.\n- Deleted notes should go through **Trash**, where restore and permanent delete are separated on purpose.\n- If tabs are hidden, `Space o` or `:buffers` becomes the fastest way to recover the current working set.\n\n## Now\n\n- [ ] Add a sample PDF + image to the tour so [[06 — Callouts and Footnotes]] can illustrate attachments and reference-pane workflows.\n- [x] Document the Tasks tab behavior in [[03 — Tables and Task Lists]].\n- [ ] Collect feedback on [[09 — Vim Cheat Sheet]] now that keymaps are configurable.\n- [ ] Confirm the search backend badge is visible enough in the vault text search palette.\n\n## Shipped\n\n- [x] Vault text search can use **Auto**, **Built-in**, **ripgrep**, or **fzf**.\n- [x] Custom binary paths can be configured when `rg` or `fzf` live outside `PATH`.\n- [x] Settings now show the resolved runtime backend instead of only the requested one.\n- [x] Archive and Trash both behave as list-style built-in tabs instead of sidebar dump zones.\n\n## Cross-references\n\n- Tour index: [[00 — Start Here]]\n- Search and links: [[07 — Wiki Links and Tags]]\n- Workspace guide: [[11 — Workspace, Search, and Views]]\n- Settings and keymaps: [[12 — Settings and Keymaps]]\n\n## A snippet I keep forgetting\n\nConverting a buffer to hex in Node:\n\n```ts\nimport { randomBytes } from 'node:crypto'\n\nconst buf = randomBytes(16)\nconsole.log(buf.toString('hex'))\n```\n\nConverting back:\n\n```ts\nconst hex = '01020304abcdef'\nconst buf = Buffer.from(hex, 'hex')\n```\n\n## Rough architecture sketch\n\n```mermaid\nflowchart TB\n subgraph Main\n V[Vault I/O]\n W[Watcher]\n T[Task scanner]\n S[Vault text search]\n end\n subgraph Renderer\n E[Editor]\n SB[Sidebar]\n P[Preview]\n O[Outline]\n C[Connections]\n end\n E <-->|IPC| V\n SB -->|IPC| V\n P -->|IPC| V\n O --> E\n C --> E\n V --> T\n V --> S\n W -->|events| V\n```\n\n## A little math\n\nThe rough cost model people keep re-deriving:\n\n$$\nT \\approx 3 \\cdot t \\cdot \\frac{m}{\\text{bandwidth}}\n$$\n\n## Workflow checklist\n\n- [ ] Try this note in **Edit**, **Split**, and **Preview**\n- [ ] Open the **outline** and jump to \"Workflow checklist\"\n- [ ] Open **Connections** and inspect backlinks\n- [ ] Search for `backend` with `Space s t`\n- [ ] Toggle **Zen mode**\n\n#demo #tasks #planning #workspace\n" + }, + { + path: "inbox/demo/11 — Workspace, Search, and Views.md", + body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n" + }, + { + path: "inbox/demo/12 — Settings and Keymaps.md", + body: "# Settings and keymaps\n\nZenNotes is keyboard-first by default, but it is not rigid anymore. Settings now cover both presentation and behavior.\n\n## Appearance\n\nFrom Settings you can tune:\n\n- theme family\n- light or dark mode\n- theme variant or contrast\n- dark sidebar treatment\n\nThe point is to keep the app comfortable for long sessions without changing the underlying note files.\n\n## Editor behavior\n\nKey editor settings include:\n\n- Vim mode on or off\n- leader key hints on or off\n- timed vs sticky leader hints\n- leader hint duration\n- live preview\n- note tabs\n- word wrap\n- PDF behavior in edit mode\n- date-titled Quick Notes\n\n## Vault text search backends\n\nVault text search can be powered by:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\nYou can also set explicit binary paths for `rg` and `fzf` in case they live outside your normal `PATH`.\n\nZenNotes now shows:\n\n- what tools are available\n- what backend is configured\n- what backend is actually being used at runtime\n\nThat matters because **Auto** can fall back, and explicit backends can also fall back when the configured binary path is missing.\n\n## Typography and layout\n\nYou can tune:\n\n- interface font\n- reading font\n- monospace font\n- editor and preview font size\n- line height\n- reading width\n- editor width\n- centered vs left-aligned content\n- line numbers\n\nThese are workflow settings, not note-format settings. The markdown file stays the same.\n\n## Keymaps\n\nKeymaps are now configurable from inside the app:\n\n- global shortcuts\n- leader sequences\n- pane-prefix motions\n- Vim-specific editor actions\n- list and view navigation\n\nThat means you can remap things like:\n\n- search notes\n- search vault text\n- toggle Zen mode\n- pane movement\n- fold motions\n- leader flows such as `Space s t`\n\nMulti-step sequences are supported, so the keymap system can handle more than single shortcuts.\n\n## Vault and About\n\nThe rest of Settings handles the vault and app identity:\n\n- reveal or change the vault location\n- inspect the app version\n- see the About section\n- find the Lumary Labs link\n- remember that Settings save automatically on this device\n\n## Practical advice\n\nIf you are learning the app:\n\n1. keep Vim mode on\n2. enable leader hints\n3. leave search backend on **Auto**\n4. only start remapping after the defaults feel familiar\n\nThat gives you the clearest path through the built-in help, demos, and keyboard flows.\n\nFor a deeper walkthrough of runtime backend selection, fallbacks, and fuzzy content search behavior, see [[15 — Search Backends and Fuzzy Workflows]].\n\n#demo #settings #keymaps #reference\n" + }, + { + path: "inbox/demo/13 — Commands, Help, and Demo Tour.md", + body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo file at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo file\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n" + }, + { + path: "inbox/demo/14 — Reference Pane and Floating Windows.md", + body: "# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local files\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n" + }, + { + path: "inbox/demo/15 — Search Backends and Fuzzy Workflows.md", + body: "# Search backends and fuzzy workflows\n\nZenNotes has two different search surfaces, and the deeper one can be powered by different backends.\n\n## Two searches, two jobs\n\n### Note search\n\nUse when you want to find a note by title or path:\n\n- `⌘P`\n- `Space f`\n\nThis is the fastest way to jump to a file you already roughly know.\n\n### Vault text search\n\nUse when you want to find matching text inside note bodies:\n\n- `Space s t`\n\nThis searches across note content and jumps directly to the matching line when you open a result.\n\n## Backends\n\nVault text search can run on:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\n### Auto\n\n`Auto` prefers:\n\n1. `fzf`\n2. `ripgrep`\n3. built-in fallback\n\nThat makes the app adapt to what is installed on the machine.\n\n### Built-in\n\nUse this when you want:\n\n- zero external dependencies\n- predictable behavior across machines\n- a search path that always exists even when no tools are installed\n\n### ripgrep\n\nUse this when you want:\n\n- strong plain-text search performance\n- system-level tooling you may already use outside the app\n- a backend that is familiar to terminal users\n\n### fzf\n\nUse this when you want:\n\n- terminal-style fuzzy matching behavior\n- ranking that feels close to launcher workflows\n- an external backend often used by Vim and Neovim users\n\n## Custom binary paths\n\nIf `rg` or `fzf` are not in your normal `PATH`, ZenNotes lets you point to them directly from Settings.\n\nExamples:\n\n- `/opt/homebrew/bin/rg`\n- `/opt/homebrew/bin/fzf`\n- `/usr/local/bin/rg`\n\nBlank means “use whatever is on PATH”.\n\n## Runtime backend vs configured backend\n\nZenNotes shows:\n\n- what you configured\n- what tools are available\n- what backend is actually being used\n\nThat distinction matters because:\n\n- `Auto` may resolve differently on different machines\n- explicit `ripgrep` or `fzf` settings can still fall back if the binary path is invalid\n\n## Search result behavior\n\nVault text search is designed to be navigational, not just informational:\n\n- results stay keyboard navigable\n- the active row stays in view while you move\n- the matching text is highlighted in the result\n- opening a result moves the cursor to the match in the note\n\nThis makes it feel more like a picker than a grep dump.\n\n## Good habits\n\n- use note search when you know the file\n- use vault text search when you only know the phrase\n- leave the backend on **Auto** unless you have a reason to force one\n- configure explicit binary paths if your tools live outside `PATH`\n\n## Related notes\n\n- [[07 — Wiki Links and Tags]] for search in the context of notes, tags, and links\n- [[11 — Workspace, Search, and Views]] for where these pickers fit into the app\n- [[12 — Settings and Keymaps]] for changing the backend and remapping the shortcut\n\n#demo #search #fzf #ripgrep #reference\n" + }, +] + +export const DEMO_TOUR_ASSETS: DemoTourTemplateFile[] = [ + { + path: "zennotes-demo-card.svg", + body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n DEMO\n ZenNotes Demo\n Local files, keyboard-first flows, and markdown-friendly structure.\n \n \n \n \n \n \n \n SEE ALSO: HELP, SEARCH, OUTLINE, TASKS, QUICK NOTES\n\n" + }, +] \ No newline at end of file diff --git a/packages/shared-domain/src/excalidraw.ts b/packages/shared-domain/src/excalidraw.ts index de013804..029fe50d 100644 --- a/packages/shared-domain/src/excalidraw.ts +++ b/packages/shared-domain/src/excalidraw.ts @@ -3,7 +3,9 @@ // Markdown notes and `.base` databases: listed in the sidebar with their own // icon, opened in a dedicated editor tab, and saved back as JSON. -import { decompressFromBase64 } from 'lz-string' +import LZString from 'lz-string' + +const { decompressFromBase64 } = LZString export const EXCALIDRAW_EXT = '.excalidraw' diff --git a/packages/shared-domain/src/mcp-clients.ts b/packages/shared-domain/src/mcp-clients.ts index 6573b06c..bddba8c7 100644 --- a/packages/shared-domain/src/mcp-clients.ts +++ b/packages/shared-domain/src/mcp-clients.ts @@ -6,7 +6,13 @@ * renderer (present the UI) rely on these constants. */ -export type McpClientId = 'claude-code' | 'claude-desktop' | 'codex' | 'opencode' +import type { McpClientId } from '@zennotes/bridge-contract/mcp-clients' +export type { + McpClientId, + McpClientStatus, + McpServerRuntime, + McpInstructionsPayload +} from '@zennotes/bridge-contract/mcp-clients' export interface McpClientDescriptor { id: McpClientId @@ -73,50 +79,3 @@ export function getMcpClientDescriptor(id: McpClientId): McpClientDescriptor { if (!found) throw new Error(`Unknown MCP client: ${id}`) return found } - -/** Serialized state returned to the renderer for the settings UI. */ -export interface McpClientStatus { - id: McpClientId - /** Absolute path to the client's config file on this machine. */ - configPath: string - /** True if the config file currently contains a ZenNotes entry. */ - installed: boolean - /** Whether the installed entry matches what we would currently install - * (same command / args / env). False when the server path changed - * because the app moved, or when an older version installed a - * different shape. */ - upToDate: boolean - /** Human-readable diagnostic — surfaced beneath the row when the - * install state is ambiguous (file missing, permission error, etc). */ - note?: string -} - -export interface McpServerRuntime { - /** Absolute path to the Node binary that will run the server. */ - command: string - /** Arguments — typically `[mcpEntryPath]`. */ - args: string[] - /** Environment variables passed to the spawned server. */ - env: Record - /** Absolute path to the compiled MCP entry file. `null` when the - * build hasn\u2019t produced it yet (dev environment without a - * prior `npm run build`). */ - entryPath: string | null - /** Set when this build cannot run or install the MCP server at all (the - * web client). The settings page shows this sentence instead of the - * runtime details and the client list (#672). */ - unavailableReason?: string -} - -/** - * Shape returned when the renderer asks for the current server-side - * instructions. `defaultValue` is the compiled default; `current` is - * what the MCP server will actually send (either the user override - * or the default); `isCustom` flags whether an override is in place. - */ -export interface McpInstructionsPayload { - defaultValue: string - current: string - isCustom: boolean - filePath: string -} diff --git a/packages/shared-domain/src/overrides.ts b/packages/shared-domain/src/overrides.ts index ceebde93..401cf04f 100644 --- a/packages/shared-domain/src/overrides.ts +++ b/packages/shared-domain/src/overrides.ts @@ -1,22 +1,4 @@ -/** - * CSS overrides — small user-authored `.css` files in - * `~/.config/zennotes/overrides/` that the user toggles on/off and that layer on - * top of *whichever* theme is active (built-in or custom). The enabled set is - * persisted as a portable config map (`[overrides]` in config.toml). - * - * To override a theme token from a override, target `:root[data-theme] { … }` — - * overrides are injected last, so that selector wins over both a built-in's - * `:root[data-theme="…"]` block and a custom theme's `:root {}`. - */ - -export interface Override { - /** Filename including `.css`, e.g. `punchy-accent.css`. Stable id. */ - name: string - /** Raw CSS text, injected verbatim when enabled. */ - css: string - /** Set when the file couldn't be read; surfaced in the UI. */ - error?: string -} +export type { Override } from '@zennotes/bridge-contract/overrides' /** * Whether a override is enabled, per the persisted `[overrides]` map. Only enabled diff --git a/packages/shared-domain/src/task-roundtrip.test.ts b/packages/shared-domain/src/task-roundtrip.test.ts new file mode 100644 index 00000000..6c2ac042 --- /dev/null +++ b/packages/shared-domain/src/task-roundtrip.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import fixtures from '../../bridge-contract/fixtures/task-roundtrip.json' +import { setTaskDueAtIndex } from './tasklists' +import { groupTasks, parseTasksFromBody, toIsoDateLocal, type ParseTasksContext } from './tasks' + +describe('shared task roundtrip fixtures', () => { + it.each(fixtures.cases)('$id', (fixture) => { + const note = fixture.note as ParseTasksContext + const before = parseTasksFromBody(fixture.body, note) + expect(before).toHaveLength(fixture.expectedTaskCount) + expect(before[fixture.taskIndex]).toMatchObject(fixture.expectedBefore) + + const localNow = fixture.localNow + ? new Date( + fixture.localNow[0], + fixture.localNow[1] - 1, + fixture.localNow[2], + fixture.localNow[3], + fixture.localNow[4] + ) + : undefined + const due = localNow ? toIsoDateLocal(localNow) : fixture.due! + const saved = setTaskDueAtIndex(fixture.body, fixture.taskIndex, due) + expect(saved).toBe(fixture.expectedBody) + + const after = parseTasksFromBody(saved, note) + expect(after).toHaveLength(fixture.expectedTaskCount) + expect(after[fixture.taskIndex]).toMatchObject(fixture.expectedAfter) + expect(after[fixture.taskIndex].id).toBe(before[fixture.taskIndex].id) + if (localNow) { + expect(groupTasks(after, localNow).today.map((task) => task.id)).toContain( + after[fixture.taskIndex].id + ) + } + }) +}) diff --git a/packages/shared-domain/src/tasks.ts b/packages/shared-domain/src/tasks.ts index 2309e0e9..2f17bb6e 100644 --- a/packages/shared-domain/src/tasks.ts +++ b/packages/shared-domain/src/tasks.ts @@ -1,3 +1,4 @@ +import type { TaskPriority, VaultTask } from '@zennotes/bridge-contract/tasks' import { parseFrontmatterFields, unquote } from './frontmatter' import type { NoteFolder } from './ipc' import { FENCE_RE, TASK_LINE_RE } from './tasklists' @@ -18,69 +19,7 @@ export function isTasksTabPath(path: string | null | undefined): boolean { // Types // --------------------------------------------------------------------------- -export type TaskPriority = 'high' | 'med' | 'low' - -export interface VaultTask { - /** Stable-ish id: `${sourcePath}#${taskIndex}`. Task index shifts only when - * tasks are added/removed above it in the same file, so this is stable - * across plain content edits. */ - id: string - /** Vault-relative POSIX path of the note containing this task. */ - sourcePath: string - /** File name without extension (for display). */ - noteTitle: string - /** Top-level vault folder the source note lives in. */ - noteFolder: NoteFolder - /** 0-based line number in the full file body (frontmatter included). */ - lineNumber: number - /** Must match `toggleTaskAtIndex` counting for round-trip edits. */ - taskIndex: number - /** Raw line as it appears on disk. */ - rawText: string - /** Display content (checkbox prefix + metadata tokens stripped). */ - content: string - checked: boolean - /** True for a `[>]` task forwarded to another note (#316). Mutually - * exclusive with `checked`; kept out of the today/upcoming/done buckets. */ - forwarded: boolean - /** True for a `[-]` task cancelled — intentionally abandoned (#450). Mutually - * exclusive with `checked`/`forwarded`; kept out of the active buckets and - * collected under its own group. */ - cancelled: boolean - /** True for a `[/]` task in progress: started, not finished (#512). Unlike - * the other non-empty state chars this one is still OPEN work, so it stays - * in Today/Upcoming, on the calendar, and on the board. It marks *how* an - * open task is going, not that it left the active set. */ - inProgress: boolean - /** ISO YYYY-MM-DD, validated via Date round-trip. */ - due?: string - /** True when `due` was *derived* from the containing daily note's date - * rather than written on the line. Lets UIs tell an implicit due apart - * from an explicit `due:` token. See `inferDailyTaskDueDates`. */ - dueInferred?: boolean - priority?: TaskPriority - /** True if `@waiting` appears anywhere on the line. */ - waiting: boolean - /** All inline `@key:value` fields on the line (lower-cased), e.g. - * `@status:review @sprint:24`. Any key can drive a Kanban group-by. Optional - * so hand-built task fixtures stay terse; the parser always sets it. (#354) */ - fields?: Record - /** Convenience accessor for `fields.status`, falling back to the note's - * `status:` frontmatter. The default Kanban custom field. (#354) */ - status?: string - /** Inline `#tags` found on the line. */ - tags: string[] - /** How this task is stored. `'file'` is a whole-note task (TaskNotes-style: - * a `.md` file tagged `#task`, metadata in frontmatter); `'inline'` (the - * default when absent) is a classic `- [ ]` checkbox line. File-tasks - * round-trip through frontmatter, not the checkbox, so mutators branch on - * this. */ - kind?: 'inline' | 'file' - /** ISO YYYY-MM-DD start/scheduled date (frontmatter `scheduled`). File-tasks. */ - scheduled?: string - /** ISO YYYY-MM-DD completion date (frontmatter `completedDate`). File-tasks. */ - completedDate?: string -} +export type { TaskPriority, VaultTask } from '@zennotes/bridge-contract/tasks' export interface VaultTaskGroups { today: VaultTask[] diff --git a/packages/shared-domain/src/vault-relocation.test.ts b/packages/shared-domain/src/vault-relocation.test.ts new file mode 100644 index 00000000..9d245898 --- /dev/null +++ b/packages/shared-domain/src/vault-relocation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { relocateVaultEntries, type VaultRelocationIO } from './vault-relocation' + +function fixture() { + const files = new Map([['notes/One.md', 'Exact café 日本語. \n'], ['comments/One.json', '{unknown fields stay intact}']]) + const io: VaultRelocationIO = { + stat: async path => files.has(path) ? 'file' : null, + mkdir: async () => {}, + rename: async (from, to) => { + if (!files.has(from) || files.has(to)) throw new Error('Collision or missing source') + files.set(to, files.get(from)!); files.delete(from) + } + } + const entries = [{ from: 'notes/One.md', to: 'notes/Two.md', required: true }, { from: 'comments/One.json', to: 'comments/Two.json' }] + return { files, io, entries, original: new Map(files) } +} +describe('portable vault relocation', () => { + it('moves exact content and sidecar bytes together', async () => { + const s = fixture(); await relocateVaultEntries(s.io, s.entries) + expect([...s.files]).toEqual([['notes/Two.md', s.original.get('notes/One.md')], ['comments/Two.json', s.original.get('comments/One.json')]]) + }) + it('preflights orphan destination comments before moving content', async () => { + const s = fixture(); s.files.set('comments/Two.json', 'Do not overwrite') + await expect(relocateVaultEntries(s.io, s.entries)).rejects.toThrow('Destination already exists') + expect(s.files.get('notes/One.md')).toBe(s.original.get('notes/One.md')) + expect(s.files.get('comments/Two.json')).toBe('Do not overwrite') + }) + it('restores the note when its sidecar move fails', async () => { + const s = fixture(), rename = s.io.rename + s.io.rename = async (from, to) => { if (from === 'comments/One.json') throw new Error('Provider refused'); await rename(from, to) } + await expect(relocateVaultEntries(s.io, s.entries)).rejects.toThrow('Provider refused') + expect(s.files).toEqual(s.original) + }) + it('rolls both moves back when metadata cannot be committed', async () => { + const s = fixture() + await expect(relocateVaultEntries(s.io, s.entries, async () => { throw new Error('Settings failed') })).rejects.toThrow('Settings failed') + expect(s.files).toEqual(s.original) + }) + it('preserves a competing file at the old path and reports an uncertain rollback', async () => { + const s = fixture() + await expect(relocateVaultEntries(s.io, s.entries, async () => { + s.files.set('notes/One.md', 'Created by another client'); throw new Error('Commit failed') + })).rejects.toThrow('FOLDER_STATE_UNCERTAIN') + expect(s.files.get('notes/One.md')).toBe('Created by another client') + expect(s.files.get('notes/Two.md')).toBe(s.original.get('notes/One.md')) + }) + it('does not treat a provider error as absence and rejects traversal', async () => { + const s = fixture(); s.io.stat = async () => { throw new Error('Permission denied') } + await expect(relocateVaultEntries(s.io, s.entries)).rejects.toThrow('Permission denied') + expect(s.files).toEqual(s.original) + await expect(relocateVaultEntries(s.io, [{ from: '../outside', to: 'note.md' }])).rejects.toThrow('vault-relative') + }) +}) diff --git a/packages/shared-domain/src/vault-relocation.ts b/packages/shared-domain/src/vault-relocation.ts new file mode 100644 index 00000000..7d772a67 --- /dev/null +++ b/packages/shared-domain/src/vault-relocation.ts @@ -0,0 +1,62 @@ +/** Host-owned filesystem operations. All paths remain vault-relative. */ +export interface VaultRelocationIO { + stat(path: string): Promise<'file' | 'directory' | null> + mkdir(path: string): Promise + /** Must either complete or leave the source in place; uncertain failures must say so. */ + rename(from: string, to: string): Promise +} + +export interface VaultRelocation { + from: string + to: string + required?: boolean +} + +function validatePath(path: string): void { + if (!path || path.startsWith('/') || /[\\\u0000]/.test(path) + || path.split('/').some(part => !part || part === '.' || part === '..')) + throw new Error('Expected a vault-relative path') +} + +/** + * Relocate content and its parallel comments together. Preflight every target, + * including orphan comments, before changing anything. Restore earlier moves + * in reverse order if a later move or the metadata commit fails. + */ +export async function relocateVaultEntries( + io: VaultRelocationIO, + entries: readonly VaultRelocation[], + commit: () => Promise = async () => {} +): Promise { + const present: VaultRelocation[] = [] + for (const entry of entries) { + validatePath(entry.from); validatePath(entry.to) + if (entry.from === entry.to) continue + if (entry.to.startsWith(`${entry.from}/`)) throw new Error('Cannot move a folder into itself') + const source = await io.stat(entry.from) + if (entry.required && source === null) throw new Error(`Missing source: ${entry.from}`) + if (await io.stat(entry.to) !== null) throw new Error(`Destination already exists: ${entry.to}`) + if (source !== null) present.push(entry) + } + const moved: VaultRelocation[] = [] + try { + for (const entry of present) { + const parent = entry.to.slice(0, entry.to.lastIndexOf('/')) + if (entry.to.includes('/')) await io.mkdir(parent) + await io.rename(entry.from, entry.to) + moved.push(entry) + } + await commit() + } catch (error) { + const failures: unknown[] = [error] + for (const entry of moved.reverse()) { + try { + if (await io.stat(entry.from) !== null) throw new Error(`Rollback path is occupied: ${entry.from}`) + await io.rename(entry.to, entry.from) + } catch (rollbackError) { failures.push(rollbackError) } + } + if (failures.length > 1) throw new AggregateError(failures, + 'FOLDER_STATE_UNCERTAIN: Could not restore a failed file operation; reload the vault before editing') + throw error + } +} diff --git a/apps/desktop/src/main/wikilink-rename.test.ts b/packages/shared-domain/src/wikilink-rename.test.ts similarity index 100% rename from apps/desktop/src/main/wikilink-rename.test.ts rename to packages/shared-domain/src/wikilink-rename.test.ts diff --git a/packages/shared-domain/src/wikilink-rename.ts b/packages/shared-domain/src/wikilink-rename.ts new file mode 100644 index 00000000..afab0f58 --- /dev/null +++ b/packages/shared-domain/src/wikilink-rename.ts @@ -0,0 +1,145 @@ +/** + * Rewriting inbound `[[wikilinks]]` when a note is renamed. + * + * The wikilink *resolution* here mirrors + * `packages/app-core/src/lib/wikilinks.ts` (the renderer's source of truth): + * a target resolves by note title (case-insensitive) unless it looks like a + * path, in which case it resolves by explicit/suffix path match. This pure implementation is + * shared by native hosts without importing the renderer bundle. + * The Go server carries an equivalent port in `internal/vault`. + */ + +export interface RenameNoteRef { + path: string + title: string + folder: string +} + +const TOP_FOLDERS = ['inbox', 'quick', 'archive', 'trash'] + +function normalizeSlashes(value: string): string { + return value.replace(/\\/g, '/').replace(/\/+/g, '/') +} + +function stripMdExtension(value: string): string { + return value.replace(/\.md$/i, '') +} + +function normalizeForCompare(value: string): string { + return value.trim().toLowerCase() +} + +export function isPathLikeWikilinkTarget(target: string): boolean { + const trimmed = target.trim() + return trimmed.startsWith('/') || trimmed.includes('/') || /\.md$/i.test(trimmed) +} + +function resolveExplicitPath(notes: RenameNoteRef[], target: string): RenameNoteRef | null { + const normalized = normalizeSlashes(target.trim()) + if (!normalized) return null + const trimmed = stripMdExtension(normalized).replace(/^\/+/, '').replace(/\/+$/, '') + if (!trimmed) return null + + let relPath: string | null = null + if (normalized.startsWith('/')) { + relPath = `inbox/${trimmed}.md` + } else if (TOP_FOLDERS.some((folder) => trimmed.toLowerCase().startsWith(`${folder}/`))) { + relPath = `${trimmed}.md` + } + if (!relPath) return null + + const needle = normalizeForCompare(relPath) + return notes.find((note) => normalizeForCompare(note.path) === needle) ?? null +} + +function resolvePathSuffix(notes: RenameNoteRef[], target: string): RenameNoteRef | null { + const trimmed = stripMdExtension(normalizeSlashes(target.trim())) + .replace(/^\/+/, '') + .replace(/\/+$/, '') + if (!trimmed) return null + + const suffix = normalizeForCompare(`/${trimmed}.md`) + const exact = normalizeForCompare(`${trimmed}.md`) + const matches = notes.filter((note) => { + const path = normalizeForCompare(note.path) + return path === exact || path.endsWith(suffix) + }) + return matches.length === 1 ? matches[0] : null +} + +export function resolveWikilinkTarget( + notes: RenameNoteRef[], + target: string +): RenameNoteRef | null { + const visible = notes.filter((note) => note.folder !== 'trash') + if (isPathLikeWikilinkTarget(target)) { + return resolveExplicitPath(visible, target) ?? resolvePathSuffix(visible, target) + } + const needle = normalizeForCompare(stripMdExtension(target)) + return visible.find((note) => normalizeForCompare(note.title) === needle) ?? null +} + +/** Split `[[ ... ]]` inner text into target, `#heading`/`^block` anchor, and + * `|alias` , the anchor/alias keep their leading delimiter so the link can be + * reassembled verbatim. */ +function splitWikilinkContent(content: string): { + target: string + anchor: string + alias: string +} { + let rest = content + let alias = '' + const pipe = rest.indexOf('|') + if (pipe >= 0) { + alias = rest.slice(pipe) + rest = rest.slice(0, pipe) + } + let anchor = '' + const anchorIdx = rest.search(/[#^]/) + if (anchorIdx >= 0) { + anchor = rest.slice(anchorIdx) + rest = rest.slice(0, anchorIdx) + } + return { target: rest, anchor, alias } +} + +/** Replace a wikilink target's final segment (the renamed file's name) with the + * new title, preserving any directory prefix, leading slash, and `.md`. */ +function swapBasename(target: string, newTitle: string): string { + const slash = target.lastIndexOf('/') + const dir = slash >= 0 ? target.slice(0, slash + 1) : '' + const base = slash >= 0 ? target.slice(slash + 1) : target + const md = base.match(/\.md$/i) + return `${dir}${newTitle}${md ? md[0] : ''}` +} + +// Matches a fenced code block, inline code, or a (possibly embedded) wikilink. +// Code is matched first so links inside code spans/blocks are left untouched. +const TOKEN_RE = /(```[\s\S]*?```|`[^`\n]*`)|(!?)\[\[([^\]\n]+?)\]\]/g + +/** + * Rewrite every inbound `[[target]]` / `![[target]]` in `body` whose target + * resolves to the note at `oldPath`, pointing it at `newTitle` instead. Aliases, + * `#heading` / `^block` anchors, and embeds are preserved; code is skipped. + * + * `notes` must reflect the pre-rename vault (the renamed note still under its + * old title/path) so resolution matches what the links currently point to. + */ +export function rewriteWikilinksForRename( + body: string, + notes: RenameNoteRef[], + oldPath: string, + newTitle: string +): { body: string; changed: number } { + let changed = 0 + const next = body.replace(TOKEN_RE, (full, code, embed, content) => { + if (code !== undefined) return full + const { target, anchor, alias } = splitWikilinkContent(content as string) + if (resolveWikilinkTarget(notes, target)?.path !== oldPath) return full + const newTarget = swapBasename(target, newTitle) + if (newTarget === target) return full + changed++ + return `${embed}[[${newTarget}${anchor}${alias}]]` + }) + return { body: next, changed } +} diff --git a/packages/shared-domain/src/workflows/apply-ops.ts b/packages/shared-domain/src/workflows/apply-ops.ts index d7e091da..7a8b51c4 100644 --- a/packages/shared-domain/src/workflows/apply-ops.ts +++ b/packages/shared-domain/src/workflows/apply-ops.ts @@ -233,7 +233,7 @@ interface CodeMap { * * The state machine is lifted from `stripCodeContent` (packages/app-core/src/ * lib/tags.ts, apps/desktop/src/main/vault.ts, apps/desktop/src/mcp/vault-ops.ts - * and apps/server/internal/vault/parse.go all carry a copy) so that a `#tag` + * and internal/vault/parse.go in ZenNotes/znserver all carry a copy) so that a `#tag` * this module writes, and a `#tag` the vault's indexer reads, can never disagree * about what is code. It returns per-line flags instead of a blanked string * because we edit the original bytes and therefore need offsets to survive. diff --git a/packages/shared-domain/src/workflows/prepare-run.ts b/packages/shared-domain/src/workflows/prepare-run.ts index aca39ca5..a1a74eb5 100644 --- a/packages/shared-domain/src/workflows/prepare-run.ts +++ b/packages/shared-domain/src/workflows/prepare-run.ts @@ -52,7 +52,7 @@ function stringField(record: Record, key: string): string | nul /** Validate one operation that crossed a process or HTTP boundary. * SYNCED COPIES: the same validator exists in apps/desktop/src/main/ * workflow-apply.ts, and the Go server keeps a field map in - * requiredWorkflowOpFields (apps/server/internal/vault/workflows.go). + * requiredWorkflowOpFields (internal/vault/workflows.go in ZenNotes/znserver). * A new op kind or field lands in all three or web and desktop disagree * about which runs are valid. */ export function parseWorkflowOp(value: unknown): WorkflowOp | null { diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 8d8a8296..7083cfb1 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.50.4", + "version": "2.51.0", "type": "module", "exports": { ".": "./src/index.ts" diff --git a/packaging/PUBLISHING.md b/packaging/PUBLISHING.md index 34df5eec..1c3d9d32 100644 --- a/packaging/PUBLISHING.md +++ b/packaging/PUBLISHING.md @@ -13,8 +13,10 @@ There are **three** Linux channels (plus Docker, which is separate): | **Nix flake** | `flake.nix` in this repo | Merge to `main` | Bump `release-data.json` (3 hashes) | | **Homebrew** (`brew install --cask`, macOS) | `packaging/homebrew/` → `ZenNotes/homebrew-tap` | You, manually | Bump + sha256 + push to the tap | -> Docker (`adibhanna/zennotes`) is handled separately by `docker-publish.yml` — -> not a Linux desktop package, not covered here. +> Docker (`adibhanna/zennotes`) is published from the server's own repository, +> [ZenNotes/znserver](https://github.com/ZenNotes/znserver), by its manual +> publisher after each server release. It is not a Linux desktop package and is +> not covered here. --- @@ -134,13 +136,11 @@ github:ZenNotes/zennotes` reads the repo's default branch (`main`), so the | `version` | `X.Y.Z` | | `hash` | `nix-prefetch-github ZenNotes zennotes --rev vX.Y.Z` | | `npmDepsHash` | `prefetch-npm-deps package-lock.json` | - | `vendorHash` | run `nix build`, read the expected hash from the mismatch error | Then verify: ```sh nix build && ./result/bin/zennotes-desktop - nix build .#zennotes-server && ./result/bin/zennotes-server ``` - **Needs Nix.** The flake targets darwin too, so you can do this on your Mac with @@ -186,10 +186,10 @@ source) and is mirrored into the **`ZenNotes/homebrew-tap`** repo, which is what `.SRCINFO`, `git push origin main:master` (§2). 7. **Nix:** bump `release-data.json`'s 3 hashes — on your Mac with Nix, or a contributor PR (§3). -8. **Docker:** confirm `docker-publish.yml` ran and pushed `adibhanna/zennotes`. +8. **Docker:** the image ships from ZenNotes/znserver on its own release cadence; nothing to do here. 9. **Homebrew (macOS):** `packaging/homebrew/update-cask.sh X.Y.Z`, commit, then mirror `Casks/zennotes.rb` into `ZenNotes/homebrew-tap` and push (§4). -**Fully automatic:** GitHub installers (incl. tar.gz once §0 lands), Docker. +**Fully automatic:** GitHub installers (incl. tar.gz once §0 lands). **Needs you every release:** AUR push, Nix hash bump, Homebrew push. **One-time future setups:** nixpkgs submission, create the Homebrew tap. diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index 4fca63ef..5e9aeaab 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -12,6 +12,7 @@ pkgbase = zennotes-bin depends = desktop-file-utils provides = zennotes conflicts = zennotes + conflicts = ZenNotes options = !strip source = ZenNotes-2.50.4-linux-x64.tar.gz::https://github.com/ZenNotes/zennotes/releases/download/v2.50.4/ZenNotes-2.50.4-linux-x64.tar.gz sha256sums = 428bdec0f436f4671c34e04a73d38a0f9345fb7119924e7938add50094703527 diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 621b4e9a..bee0654c 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -21,7 +21,7 @@ license=('MIT') # without it, zennotes:// deep links silently go nowhere on a minimal install. depends=('gtk3' 'nss' 'alsa-lib' 'libxss' 'desktop-file-utils') provides=('zennotes') -conflicts=('zennotes') +conflicts=('zennotes' 'ZenNotes') options=('!strip') source=( diff --git a/packaging/nix/README.md b/packaging/nix/README.md index fdc35849..32059557 100644 --- a/packaging/nix/README.md +++ b/packaging/nix/README.md @@ -11,10 +11,9 @@ For the desktop app: nix run github:ZenNotes/zennotes ``` -For the server: -```sh -nix run github:ZenNotes/zennotes#zennotes-server -``` +The self-hosted server is packaged in its own repository, +[ZenNotes/znserver](https://github.com/ZenNotes/znserver), which ships a +`default.nix` (`nix-build` in a checkout). ## Installing on NixOS @@ -41,7 +40,6 @@ And then you can add it to your system packages: { environment.systemPackages = [ inputs.zennotes.packages.${pkgs.system}.zennotes-desktop - inputs.zennotes.packages.${pkgs.system}.zennotes-server ]; } ``` @@ -55,14 +53,6 @@ environment.systemPackages = [ ]; ``` -Same goes for the server package: - -```nix -environment.systemPackages = [ - (pkgs.callPackage ./package-server.nix { }) -]; -``` - ## Updating to a new release 1. Open `release-data.json` @@ -91,7 +81,7 @@ nix-prefetch-github ZenNotes zennotes --rev "vX.X.X" ``` -3. Update the npmDepsHash (if needed) and vendorHash (if needed) +3. Update the npmDepsHash (if needed) To obtain a new npmDepsHash use this command in an updated project root: ```sh @@ -101,8 +91,7 @@ prefetch-npm-deps package-lock.json ```json { // ... - "npmDepsHash": "sha256-7IpGnxVjaJvfSZyKjOylGMhFqa1bx8Ry5O1yqYfNnCE=", - "vendorHash": "sha256-wYBF7CjM6AvoWMWql9hFmIaj6pCmli4vOef6POyGkfU=" + "npmDepsHash": "sha256-7IpGnxVjaJvfSZyKjOylGMhFqa1bx8Ry5O1yqYfNnCE=" } ``` @@ -113,11 +102,6 @@ nix build ./result/bin/zennotes-desktop ``` -```sh -nix build .#server -./result/bin/zennotes-server -``` - ## Notes & limitations * Automatic updates inside ZenNotes are disabled because Nix packages are immutable. diff --git a/packaging/nix/package-server.nix b/packaging/nix/package-server.nix deleted file mode 100644 index dced319a..00000000 --- a/packaging/nix/package-server.nix +++ /dev/null @@ -1,66 +0,0 @@ -{ - lib, - buildGoModule, - fetchFromGitHub, - buildNpmPackage, -}: -let - releaseData = lib.importJSON ./release-data.json; - - src = fetchFromGitHub { - owner = "ZenNotes"; - repo = "zennotes"; - tag = "v${releaseData.version}"; - inherit (releaseData) hash; - }; - - web = buildNpmPackage { - pname = "zennotes-web"; - - inherit (releaseData) version npmDepsHash; - inherit src; - - npmWorkspace = "apps/web"; - - env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; - - installPhase = '' - runHook preInstall - - mkdir -p "$out" - cp -R apps/web/dist/. "$out/" - - runHook postInstall - ''; - }; -in -buildGoModule (finalAttrs: { - pname = "zennotes-server"; - - inherit (releaseData) version vendorHash; - inherit src; - - modRoot = "apps/server"; - - subPackages = [ "cmd/zennotes-server" ]; - ldflags = [ - "-s" - "-w" - ]; - - preBuild = '' - rm -rf web/dist - mkdir -p web/dist - cp -R ${web}/. web/dist/ - ''; - - meta = { - description = "A server API for hosting remote ZenNotes vaults"; - homepage = "https://zennotes.org/"; - changelog = "https://github.com/ZenNotes/zennotes/releases/tag/v${finalAttrs.version}"; - license = lib.licenses.mit; - maintainers = with lib.maintainers; [ justkrysteq ]; - mainProgram = finalAttrs.pname; - platforms = lib.platforms.linux ++ lib.platforms.darwin; - }; -}) diff --git a/packaging/nix/release-data.json b/packaging/nix/release-data.json index 09991ed1..e36476c1 100644 --- a/packaging/nix/release-data.json +++ b/packaging/nix/release-data.json @@ -2,6 +2,5 @@ "version": "2.50.4", "hash": "sha256-E3KparZ1Gc7hcUW5fQnmYoQs2IQNxz+NQZFMJEy5gG4=", "npmDepsHash": "sha256-Nr2f0H6UT2eorlr60BjDl87OQzJRLWjVEgreJnHCMxg=", - "vendorHash": "sha256-ZdOHC2JldvnKSDUFnBUJrKD4F1IWfvYJBksgeDnU9cw=", "desktopHash": "sha256-QovewPQ29GccNOBKc9OKD5NF+3EZkk55OK3VAJRwNSc=" } diff --git a/tooling/scripts/build-go-server.mjs b/tooling/scripts/build-go-server.mjs deleted file mode 100644 index c1fb5baa..00000000 --- a/tooling/scripts/build-go-server.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawn } from 'node:child_process' - -import { withGoEnv } from './go-env.mjs' - -const scriptDir = dirname(fileURLToPath(import.meta.url)) -const repoRoot = resolve(scriptDir, '..', '..') -const serverRoot = resolve(repoRoot, 'apps/server') -const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' -const serverBinaryName = process.platform === 'win32' ? 'zennotes-server.exe' : 'zennotes-server' - -function run(command, args, cwd = repoRoot, options = {}) { - const shell = options.shell ?? false - return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(command, args, { - cwd, - env: options.env ?? process.env, - stdio: 'inherit', - shell - }) - - child.on('exit', (code) => { - if (code === 0) { - resolvePromise() - return - } - rejectPromise(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 'unknown'}`)) - }) - - child.on('error', rejectPromise) - }) -} - -await run(npmCommand, ['run', 'sync-web', '--workspace', '@zennotes/server'], repoRoot, { - shell: process.platform === 'win32' -}) - -await run( - 'go', - [ - 'build', - '-trimpath', - '-ldflags=-s -w', - '-o', - resolve(serverRoot, 'bin', serverBinaryName), - './cmd/zennotes-server' - ], - serverRoot, - { - env: withGoEnv() - } -) diff --git a/tooling/scripts/collect-app-core-evidence.mjs b/tooling/scripts/collect-app-core-evidence.mjs new file mode 100644 index 00000000..489abe09 --- /dev/null +++ b/tooling/scripts/collect-app-core-evidence.mjs @@ -0,0 +1,36 @@ +import { cp, mkdir, readFile, readdir, stat } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +// Copy the browser harness evidence (screenshots, page dumps, request logs) +// out of the throwaway consumer directory so CI can upload it. The Chrome +// profile and the built consumer stay behind; they are large and reproducible. +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const target = resolve(process.argv[2] || join(root, 'dist/app-core-browser-evidence')) +const manifest = join(root, 'dist/shared-packages/app-core-consumer.json') +let consumer +try { + consumer = JSON.parse(await readFile(manifest, 'utf8')).consumer +} catch { + console.log('No consumer manifest; nothing to collect.') + process.exit(0) +} +let copied = 0 +for (const entry of await readdir(consumer, { withFileTypes: true }).catch(() => [])) { + if (!entry.isDirectory() || !entry.name.startsWith('browser-')) continue + const source = join(consumer, entry.name) + for (const file of await readdir(source)) { + if (!/\.(?:png|json|txt)$/.test(file)) continue + if (!(await stat(join(source, file))).isFile()) continue + await mkdir(join(target, entry.name), { recursive: true }) + await cp(join(source, file), join(target, entry.name, file)) + copied++ + } +} +const result = join(consumer, 'result.json') +if (await stat(result).then((info) => info.isFile()).catch(() => false)) { + await mkdir(target, { recursive: true }) + await cp(result, join(target, 'package-result.json')) + copied++ +} +console.log(`Collected ${copied} evidence files into ${target}`) diff --git a/tooling/scripts/pack-app-core.mjs b/tooling/scripts/pack-app-core.mjs new file mode 100644 index 00000000..4f3fe704 --- /dev/null +++ b/tooling/scripts/pack-app-core.mjs @@ -0,0 +1,123 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { constants } from 'node:fs' +import { copyFile, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { filesIn, packSharedPackage, resolvePublishedImports, runNpm, tsconfigPath } from './pack-shared-package.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const packageRoot = join(root, 'packages/app-core') +const require = createRequire(join(packageRoot, 'package.json')) + +export async function packAppCore() { + const contract = await packSharedPackage('bridge-contract') + const domain = await packSharedPackage('shared-domain') + const source = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) + const stage = await mkdtemp(join(tmpdir(), 'zennotes-app-core-package-')) + try { + const config = { + extends: tsconfigPath(join(packageRoot, 'tsconfig.json')), + compilerOptions: { + composite: false, declaration: true, noEmit: false, types: [], + rootDir: tsconfigPath(join(root, 'packages')), outDir: tsconfigPath(join(stage, 'emit')) + }, + include: [tsconfigPath(join(packageRoot, 'src/**/*.ts')), tsconfigPath(join(packageRoot, 'src/**/*.tsx'))], + exclude: [tsconfigPath(join(packageRoot, 'src/**/*.test.ts')), tsconfigPath(join(packageRoot, 'src/**/*.test.tsx'))] + } + await writeFile(join(stage, 'tsconfig.json'), JSON.stringify(config)) + execFileSync(process.execPath, [require.resolve('typescript/bin/tsc'), '-p', join(stage, 'tsconfig.json')], { cwd: root, stdio: 'inherit' }) + const payload = join(stage, 'package') + await mkdir(payload) + // TypeScript follows source aliases while checking. Ship only app-core's + // emitted files; contract/domain are separately pinned dependencies. + await cp(join(stage, 'emit/app-core/src'), join(payload, 'dist'), { recursive: true }) + for (const file of await filesIn(join(packageRoot, 'src'))) { + if (/\.(?:ts|tsx)$/.test(file) && !file.endsWith('.d.ts')) continue + const target = join(payload, 'dist', relative(join(packageRoot, 'src'), file)) + await mkdir(dirname(target), { recursive: true }) + await cp(file, target) + } + await resolvePublishedImports(join(payload, 'dist'), require('typescript'), { + '@shared/': '@zennotes/shared-domain/', + '@bridge-contract/': '@zennotes/bridge-contract/' + }) + + const theme = require(join(packageRoot, 'build/tailwind-preset.cjs')) + const css = await require('postcss')([ + require('tailwindcss')({ ...theme, content: [join(payload, 'dist/**/*.js')] }), + require('autoprefixer')() + ]).process(await readFile(join(packageRoot, 'src/styles/index.css'), 'utf8'), { from: undefined }) + await writeFile(join(payload, 'dist/styles/index.css'), css.css) + await cp(join(packageRoot, 'build'), join(payload, 'build'), { recursive: true }) + await cp(join(root, 'LICENSE'), join(payload, 'LICENSE')) + await cp(join(packageRoot, 'README.md'), join(payload, 'README.md')) + + const exports = { + './main': { types: './dist/main.d.ts', import: './dist/main.js' }, + './navigation': { types: './dist/navigation.d.ts', import: './dist/navigation.js' }, + './notes': { types: './dist/notes.d.ts', import: './dist/notes.js' }, + './shell': { types: './dist/shell.d.ts', import: './dist/shell.js' }, + './browse': { types: './dist/browse.d.ts', import: './dist/browse.js' }, + './tasks': { types: './dist/tasks.d.ts', import: './dist/tasks.js' }, + './workspace': { types: './dist/workspace.d.ts', import: './dist/workspace.js' }, + './settings': { types: './dist/settings.d.ts', import: './dist/settings.js' }, + './commands': { types: './dist/commands.d.ts', import: './dist/commands.js' }, + './dialogs': { types: './dist/dialogs.d.ts', import: './dist/dialogs.js' }, + './host': { types: './dist/host.d.ts', import: './dist/host.js' }, + './editor': { types: './dist/editor.d.ts', import: './dist/editor.js' }, + './styles.css': './dist/styles/index.css', + './vite': { types: './build/vite.d.ts', import: './build/vite.mjs' } + } + const dependencies = { + ...source.dependencies, + [contract.name]: contract.version, + [domain.name]: domain.version + } + const peerDependencies = { vite: '^6.4.3 || ^7.0.0 || ^8.0.0' } + for (const name of ['react', 'react-dom', 'zustand', '@codemirror/state', '@codemirror/view', '@codemirror/language', '@lezer/common', '@lezer/highlight']) { + peerDependencies[name] = dependencies[name] + delete dependencies[name] + } + const metadata = { + name: source.name, type: 'module', license: 'MIT', exports, + files: ['dist', 'build', 'LICENSE'], dependencies, peerDependencies, + peerDependenciesMeta: { vite: { optional: true } } + } + const hash = createHash('sha256').update(JSON.stringify(metadata)) + for (const file of (await filesIn(payload)).sort()) { + const bytes = await readFile(file) + hash.update(`${relative(payload, file).split('\\').join('/')}\0${bytes.length}\0`) + hash.update(bytes) + } + const version = `${source.version}-core.h${hash.digest('hex').slice(0, 16)}` + await writeFile(join(payload, 'package.json'), JSON.stringify({ ...metadata, version }, null, 2) + '\n') + const packed = JSON.parse(runNpm(['pack', '--json', '--ignore-scripts'], { cwd: payload, encoding: 'utf8' }))[0] + const output = join(root, 'dist/shared-packages') + await mkdir(output, { recursive: true }) + const archive = join(output, packed.filename) + const bytes = await readFile(join(payload, packed.filename)) + try { await copyFile(join(payload, packed.filename), archive, constants.COPYFILE_EXCL) } + catch (error) { + if (error.code !== 'EEXIST') throw error + if (!bytes.equals(await readFile(archive))) throw new Error(`Package candidate already exists with different bytes: ${version}`) + } + const manifest = { + name: source.name, version, file: packed.filename, archive, + sha256: createHash('sha256').update(bytes).digest('hex'), integrity: packed.integrity, + sourceCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), + workingTreeDirty: execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim().length > 0, + sourceLockSha256: createHash('sha256').update(await readFile(join(root, 'package-lock.json'))).digest('hex'), + toolchain: { node: process.version, typescript: require('typescript/package.json').version, tailwind: require('tailwindcss/package.json').version }, + dependencies: [contract, domain] + } + await writeFile(`${archive}.json`, JSON.stringify(manifest, null, 2) + '\n') + return manifest + } finally { await rm(stage, { recursive: true, force: true }) } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.stdout.write(JSON.stringify(await packAppCore(), null, 2) + '\n') +} diff --git a/tooling/scripts/pack-share-viewer.mjs b/tooling/scripts/pack-share-viewer.mjs new file mode 100644 index 00000000..f2929185 --- /dev/null +++ b/tooling/scripts/pack-share-viewer.mjs @@ -0,0 +1,23 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { packWebDistribution } from './pack-web-artifact.mjs' +import { runNpm } from './pack-shared-package.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +runNpm(['run', 'build', '--workspace', '@zennotes/share-viewer'], { cwd: root, stdio: 'inherit' }) +const product = JSON.parse(await readFile(join(root, 'apps/share-viewer/package.json'), 'utf8')) +const result = await packWebDistribution({ + target: 'viewer', distribution: join(root, 'apps/share-viewer/dist'), output: join(root, 'dist/viewer-artifacts'), + productVersion: product.version, + source: { + repository: 'https://github.com/ZenNotes/zennotes', + commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), + dirty: execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim().length > 0, + lockfileSha256: createHash('sha256').update(await readFile(join(root, 'package-lock.json'))).digest('hex') + }, + toolchain: { node: process.version, npm: runNpm(['--version'], { encoding: 'utf8' }).trim() } +}) +console.log(JSON.stringify({ version: result.manifest.version, archive: result.archivePath, manifest: result.manifestPath }, null, 2)) diff --git a/tooling/scripts/pack-shared-package.mjs b/tooling/scripts/pack-shared-package.mjs new file mode 100644 index 00000000..cdaa34ce --- /dev/null +++ b/tooling/scripts/pack-shared-package.mjs @@ -0,0 +1,176 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { constants } from 'node:fs' +import { copyFile, cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const packageNames = ['bridge-contract', 'shared-domain'] + +// tsc reads include and exclude as glob patterns, and globs only understand +// forward slashes, so a Windows path.join result matches nothing (TS18003). +export function tsconfigPath(path) { + return path.split(sep).join('/') +} + +export function runNpm(args, options) { + const cli = process.env.npm_execpath + if (cli) return execFileSync(process.execPath, [cli, ...args], options) + if (process.platform === 'win32') { + throw new Error('Run this script through npm run so the npm JavaScript CLI is available on Windows.') + } + return execFileSync('npm', args, options) +} + +export async function filesIn(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + const groups = await Promise.all(entries.map((entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() ? filesIn(path) : [path] + })) + return groups.flat() +} + +async function isFile(path) { + try { return (await stat(path)).isFile() } catch (error) { + if (error.code === 'ENOENT') return false + throw error + } +} + +async function candidateSuffix() { + const inputs = ['LICENSE', 'tsconfig.base.json', 'package-lock.json', 'tooling/scripts/pack-shared-package.mjs'] + .map((path) => join(repoRoot, path)) + for (const name of packageNames) { + const root = join(repoRoot, 'packages', name) + inputs.push(join(root, 'package.json'), join(root, 'tsconfig.json'), ...await filesIn(join(root, 'src'))) + if (name === 'bridge-contract') inputs.push(...await filesIn(join(root, 'fixtures'))) + } + const hash = createHash('sha256') + hash.update(execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot })) + for (const file of inputs.sort()) { + const bytes = await readFile(file) + hash.update(`${relative(repoRoot, file).replace(/\\/g, '/')}\0${bytes.length}\0`) + hash.update(bytes) + } + return `boundaries.h${hash.digest('hex').slice(0, 16)}` +} + +// Source workspaces use extensionless imports. Published ESM and declarations +// need resolvable file extensions; rewrite only module specifiers, never note data. +export async function resolvePublishedImports(directory, ts, aliases = {}) { + for (const file of await filesIn(directory)) { + if (!file.endsWith('.js') && !file.endsWith('.ts')) continue + const source = await readFile(file, 'utf8') + const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true) + const literals = [] + function visit(node) { + let specifier + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) specifier = node.moduleSpecifier + if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) specifier = node.argument.literal + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) specifier = node.arguments[0] + if (specifier && ts.isStringLiteral(specifier)) literals.push(specifier) + ts.forEachChild(node, visit) + } + visit(ast) + let rewritten = source + for (const literal of literals.sort((a, b) => b.pos - a.pos)) { + const alias = Object.keys(aliases).find((prefix) => literal.text.startsWith(prefix)) + if (alias) { + const replacement = aliases[alias] + literal.text.slice(alias.length) + rewritten = rewritten.slice(0, literal.getStart(ast) + 1) + replacement + rewritten.slice(literal.end - 1) + continue + } + if (!literal.text.startsWith('.')) continue + if (/\.(?:js|mjs|cjs|json)$/.test(literal.text)) continue + if (await isFile(resolve(dirname(file), literal.text.split('?')[0]))) continue + const target = resolve(dirname(file), literal.text) + const suffix = await isFile(`${target}.js`) ? '.js' + : await isFile(join(target, 'index.js')) ? '/index.js' : null + if (!suffix) throw new Error(`Unresolved published import ${literal.text} in ${file}`) + rewritten = rewritten.slice(0, literal.getStart(ast) + 1) + literal.text + suffix + rewritten.slice(literal.end - 1) + } + if (rewritten !== source) await writeFile(file, rewritten) + } +} + +export async function packSharedPackage(name, candidateVersion) { + if (!packageNames.includes(name)) throw new Error(`Unsupported shared package: ${name}`) + const packageRoot = join(repoRoot, 'packages', name) + const source = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) + const version = candidateVersion ?? `${source.version}-${await candidateSuffix()}` + const output = join(repoRoot, 'dist/shared-packages') + const stage = await mkdtemp(join(tmpdir(), `zennotes-${name}-package-`)) + const require = createRequire(join(packageRoot, 'package.json')) + try { + const config = { + extends: tsconfigPath(join(packageRoot, 'tsconfig.json')), + compilerOptions: { + composite: false, declaration: true, types: [], lib: ['ES2022', 'DOM'], + rootDir: tsconfigPath(join(packageRoot, 'src')), outDir: tsconfigPath(join(stage, 'dist')) + }, + include: [tsconfigPath(join(packageRoot, 'src/**/*.ts'))], + exclude: [tsconfigPath(join(packageRoot, 'src/**/*.test.ts'))] + } + await writeFile(join(stage, 'tsconfig.json'), JSON.stringify(config)) + execFileSync(process.execPath, [require.resolve('typescript/bin/tsc'), '-p', join(stage, 'tsconfig.json')], { + cwd: repoRoot, stdio: 'inherit' + }) + await resolvePublishedImports(join(stage, 'dist'), require('typescript')) + const exports = Object.fromEntries(Object.entries(source.exports).map(([key, path]) => { + const entry = path.replace('./src/', './dist/').replace(/\.ts$/, '') + return [key, { types: `${entry}.d.ts`, import: `${entry}.js`, default: `${entry}.js` }] + })) + for (const [key, targets] of Object.entries(exports)) { + if (key.includes('*')) continue + for (const target of new Set(Object.values(targets))) { + if (!await isFile(join(stage, target))) throw new Error(`Missing export target ${key}: ${target}`) + } + } + const dependencies = Object.fromEntries(Object.entries(source.dependencies ?? {}).map(([key, value]) => [ + key, key.startsWith('@zennotes/') ? version : value + ])) + const files = ['dist', 'LICENSE'] + if (name === 'bridge-contract') { + await cp(join(packageRoot, 'fixtures'), join(stage, 'fixtures'), { recursive: true }) + files.push('fixtures') + } + await cp(join(repoRoot, 'LICENSE'), join(stage, 'LICENSE')) + await writeFile(join(stage, 'package.json'), JSON.stringify({ + name: source.name, version, type: 'module', license: 'MIT', exports, files, dependencies + }, null, 2) + '\n') + await mkdir(output, { recursive: true }) + const packed = JSON.parse(runNpm([ + 'pack', '--json', '--ignore-scripts' + ], { cwd: stage, encoding: 'utf8' }))[0] + const stagedArchive = join(stage, packed.filename) + const archive = join(output, packed.filename) + const bytes = await readFile(stagedArchive) + try { + await copyFile(stagedArchive, archive, constants.COPYFILE_EXCL) + } catch (error) { + if (error.code !== 'EEXIST') throw error + if (!bytes.equals(await readFile(archive))) { + throw new Error(`Candidate ${version} already exists with different bytes. Choose a new version.`) + } + } + const manifest = { + name: source.name, version, file: packed.filename, + sha256: createHash('sha256').update(bytes).digest('hex'), + integrity: packed.integrity, + sourceCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim(), + workingTreeDirty: execFileSync('git', ['status', '--porcelain'], { cwd: repoRoot, encoding: 'utf8' }).trim().length > 0 + } + await writeFile(`${archive}.json`, JSON.stringify(manifest, null, 2) + '\n') + return { archive, ...manifest } + } finally { + await rm(stage, { recursive: true, force: true }) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.stdout.write(JSON.stringify(await packSharedPackage(process.argv[2], process.argv[3]), null, 2) + '\n') +} diff --git a/tooling/scripts/pack-web-artifact.mjs b/tooling/scripts/pack-web-artifact.mjs new file mode 100644 index 00000000..47450be5 --- /dev/null +++ b/tooling/scripts/pack-web-artifact.mjs @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { runNpm } from './pack-shared-package.mjs' +import { webDistLockEnv, withWebDistLock } from './web-dist-lock.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex') + +async function inventory(directory, prefix = '') { + const files = [] + for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + )) { + const path = prefix + entry.name + if (entry.isDirectory()) + files.push(...(await inventory(join(directory, entry.name), `${path}/`))) + else if (entry.isFile()) { + const bytes = await readFile(join(directory, entry.name)) + files.push({ path, size: bytes.length, sha256: sha256(bytes) }) + } else throw new Error(`Browser artifacts must contain regular files: ${path}`) + } + return files +} + +async function writeImmutable(path, bytes) { + try { + await writeFile(path, bytes, { flag: 'wx' }) + } catch (error) { + if (error.code !== 'EEXIST') throw error + if (!bytes.equals(await readFile(path))) + throw new Error(`Artifact already exists with different bytes: ${path}`) + } +} + +export async function packWebDistribution({ + distribution, + output, + productVersion, + source, + toolchain, + license = join(root, 'LICENSE'), + target = 'web' +}) { + if ( + typeof source?.dirty !== 'boolean' || + source.repository !== 'https://github.com/ZenNotes/zennotes' || + !/^[a-f0-9]{40}$/.test(source.commit) + ) { + throw new Error( + 'Artifact source must include its repository, commit, and explicit dirty boolean' + ) + } + if (!['web', 'viewer'].includes(target)) throw new Error('Unknown frontend artifact target') + const viewer = target === 'viewer' + const stage = await mkdtemp(join(tmpdir(), 'zennotes-web-artifact-')) + try { + await cp(distribution, join(stage, 'dist'), { + recursive: true, + verbatimSymlinks: true + }) + const licenseBytes = await readFile(license) + // The viewer is installed as static files; retain its license in that tree. + if (viewer) await writeFile(join(stage, 'dist', 'LICENSE'), licenseBytes, { flag: 'wx' }) + const files = await inventory(join(stage, 'dist')) + const entrypoints = viewer ? ['share-viewer.js', 'share-viewer.css'] : ['index.html', 'sw.js', 'manifest.webmanifest'] + for (const path of entrypoints) { + if (!files.some((file) => file.path === path && file.size > 0)) + throw new Error(`Missing browser entrypoint: ${path}`) + } + const identity = { + schemaVersion: 1, + artifact: viewer ? 'zennotes-share-viewer' : 'zennotes-self-hosted-web', + protocol: viewer ? 'share-page-payload-v1' : 'self-hosted-http-v1', + source, + toolchain, + entrypoints, + files + } + const version = `${productVersion}-${target}.h${sha256(JSON.stringify({ ...identity, licenseSha256: sha256(licenseBytes), packFormat: 1 })).slice(0, 16)}` + await writeFile(join(stage, 'LICENSE'), licenseBytes) + await writeFile( + join(stage, 'package.json'), + JSON.stringify( + { + name: viewer ? '@zennotes/share-viewer-dist' : '@zennotes/self-hosted-web', + version, + private: true, + license: 'MIT', + files: ['dist', 'LICENSE'] + }, + null, + 2 + ) + '\n' + ) + const packed = JSON.parse( + runNpm(['pack', '--json', '--ignore-scripts'], { + cwd: stage, + encoding: 'utf8' + }) + )[0] + const bytes = await readFile(join(stage, packed.filename)) + const archive = { + file: packed.filename, + size: bytes.length, + sha256: sha256(bytes), + ...(!source.dirty + ? { + url: `https://github.com/ZenNotes/zennotes/releases/download/${target}-${version}/${packed.filename}` + } + : {}) + } + const manifest = { ...identity, version, archive } + await mkdir(output, { recursive: true }) + const archivePath = join(output, packed.filename) + const manifestPath = `${archivePath}.json` + await writeImmutable(archivePath, bytes) + await writeImmutable(manifestPath, Buffer.from(JSON.stringify(manifest, null, 2) + '\n')) + return { archivePath, manifestPath, manifest } + } finally { + await rm(stage, { recursive: true, force: true }) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await withWebDistLock(async (lock) => { + const source = { + repository: 'https://github.com/ZenNotes/zennotes', + commit: execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8' + }).trim(), + dirty: + execFileSync('git', ['status', '--porcelain'], { + cwd: root, + encoding: 'utf8' + }).trim().length > 0, + lockfileSha256: sha256(await readFile(join(root, 'package-lock.json'))) + } + runNpm(['run', 'build', '--workspace', '@zennotes/web'], { + cwd: root, + env: webDistLockEnv(lock), + stdio: 'inherit' + }) + const product = JSON.parse(await readFile(join(root, 'apps/web/package.json'), 'utf8')) + const result = await packWebDistribution({ + distribution: join(root, 'apps/web/dist'), + output: join(root, 'dist/web-artifacts'), + productVersion: product.version, + source, + toolchain: { + node: process.version, + npm: runNpm(['--version'], { encoding: 'utf8' }).trim() + } + }) + process.stdout.write( + JSON.stringify( + { + version: result.manifest.version, + archive: result.archivePath, + manifest: result.manifestPath + }, + null, + 2 + ) + '\n' + ) + }) +} diff --git a/tooling/scripts/pack-web-artifact.test.mjs b/tooling/scripts/pack-web-artifact.test.mjs new file mode 100644 index 00000000..7c707138 --- /dev/null +++ b/tooling/scripts/pack-web-artifact.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { packWebDistribution } from './pack-web-artifact.mjs' + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'zennotes web artifact & test ')) + t.after(() => rm(root, { recursive: true, force: true })) + const distribution = join(root, 'dist') + await mkdir(distribution) + await writeFile(join(distribution, 'index.html'), '') + await writeFile(join(distribution, 'app.js'), 'console.log("first")') + await writeFile(join(distribution, 'sw.js'), '/* service worker */') + await writeFile(join(distribution, 'manifest.webmanifest'), '{}') + return { + distribution, + output: join(root, 'output'), + productVersion: '1.0.0', + source: { + repository: 'https://github.com/ZenNotes/zennotes', + commit: 'a'.repeat(40), + dirty: true + }, + toolchain: { node: process.version } + } +} + +test('identical browser bytes produce an identical archive; changed bytes produce a new pin', async (t) => { + const options = await fixture(t) + const first = await packWebDistribution(options) + const repeated = await packWebDistribution(options) + assert.deepEqual(first, repeated) + assert.equal(first.manifest.archive.url, undefined) + assert.deepEqual( + first.manifest.files.map((file) => file.path), + ['app.js', 'index.html', 'manifest.webmanifest', 'sw.js'] + ) + await writeFile(join(options.distribution, 'app.js'), 'console.log("second")') + const changed = await packWebDistribution(options) + assert.notEqual(changed.manifest.version, first.manifest.version) + assert.notEqual(changed.manifest.archive.sha256, first.manifest.archive.sha256) + assert.ok((await readFile(first.archivePath)).length > 0) +}) + +test('refuses source symlinks and missing browser entrypoints', async (t) => { + const options = await fixture(t) + await rm(join(options.distribution, 'sw.js')) + await assert.rejects(packWebDistribution(options), /Missing browser entrypoint/) + try { + await symlink('app.js', join(options.distribution, 'sw.js')) + } catch (error) { + if (error.code === 'EPERM') { + t.skip('symbolic links require OS permission') + return + } + throw error + } + await assert.rejects(packWebDistribution(options), /regular files/) +}) + +test('requires explicit source provenance before packing', async (t) => { + const options = await fixture(t) + delete options.source.dirty + await assert.rejects(packWebDistribution(options), /explicit dirty boolean/) +}) + +test('viewer archives have a distinct protocol, entrypoints and immutable identity', async (t) => { + const options = await fixture(t) + options.target = 'viewer' + await assert.rejects(packWebDistribution(options), /Missing browser entrypoint/) + await writeFile(join(options.distribution, 'share-viewer.js'), 'console.log("read only")') + await writeFile(join(options.distribution, 'share-viewer.css'), 'body { color: black }') + const first = await packWebDistribution(options) + assert.equal(first.manifest.artifact, 'zennotes-share-viewer') + assert.equal(first.manifest.protocol, 'share-page-payload-v1') + assert.deepEqual(first.manifest.entrypoints, ['share-viewer.js', 'share-viewer.css']) + assert.match(first.manifest.version, /^1\.0\.0-viewer\.h[a-f0-9]{16}$/) + assert.deepEqual(await packWebDistribution(options), first) + await writeFile(join(options.distribution, 'share-viewer.css'), 'body { color: blue }') + assert.notEqual((await packWebDistribution(options)).manifest.version, first.manifest.version) +}) + +// The producer must not follow input links while adding the static license. +test('viewer license symlinks cannot overwrite files outside staging', async (t) => { + const options = await fixture(t) + const target = join(options.distribution, '..', 'outside.txt') + await writeFile(target, 'unchanged') + try { await symlink(target, join(options.distribution, 'LICENSE')) } catch (error) { + if (error.code === 'EPERM') { t.skip('symbolic links require OS permission'); return } + throw error + } + await assert.rejects(packWebDistribution({ ...options, target: 'viewer' }), /EEXIST/) + assert.equal(await readFile(target, 'utf8'), 'unchanged') +}) diff --git a/tooling/scripts/perf-desktop-runtime.mjs b/tooling/scripts/perf-desktop-runtime.mjs index 0b51b994..cf7e5840 100644 --- a/tooling/scripts/perf-desktop-runtime.mjs +++ b/tooling/scripts/perf-desktop-runtime.mjs @@ -10,11 +10,10 @@ import { fileURLToPath } from 'node:url' import WebSocket from 'ws' -const require = createRequire(import.meta.url) -const electronPath = require('electron') - const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') +const requireDesktop = createRequire(resolve(repoRoot, 'apps/desktop/package.json')) +const electronPath = requireDesktop('electron') const desktopOutMain = resolve(repoRoot, 'apps/desktop/out/main/index.js') const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' @@ -301,6 +300,7 @@ function startDesktopRuntime({ debugPort, userDataRoot, disablePersistedMetaCach ELECTRON_DISABLE_SECURITY_WARNINGS: '1', ZEN_PERF: '1', ZENNOTES_USER_DATA_PATH: userDataRoot, + ZENNOTES_CONFIG_DIR: join(userDataRoot, 'config'), ...(disablePersistedMetaCache ? { ZEN_PERF_DISABLE_PERSISTED_META_CACHE: '1' } : {}) }, stdio: ['ignore', 'pipe', 'pipe'] @@ -694,6 +694,12 @@ async function main() { 'desktop workspace ready' ) + await waitForExpression(client, `(() => { + const skip = [...document.querySelectorAll('button')].find((button) => button.textContent.trim() === 'Skip setup'); + skip?.click(); + return Boolean(document.querySelector('[data-sidebar-type], [data-notelist-path]')); + })()`, 10000, 'desktop navigation after first-run setup') + const inboxExpansion = await evaluate( client, `(async () => { @@ -1078,6 +1084,18 @@ async function main() { printMetric('metadata cache wait', cacheWaitMs) } } + } catch (error) { + if (client && keepTempRoot) { + try { + const page = await evaluate(client, 'document.body.innerText') + await writeFile(join(tempRoot, 'failure-page.txt'), page) + const screenshot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(tempRoot, 'failure-page.png'), Buffer.from(screenshot.data, 'base64')) + } catch (diagnosticError) { + console.error('Could not capture failure diagnostics:', diagnosticError) + } + } + throw error } finally { client?.close() await stopChild(electron?.child) diff --git a/tooling/scripts/perf-web-runtime.mjs b/tooling/scripts/perf-web-runtime.mjs index 7611695a..8fcce2e6 100644 --- a/tooling/scripts/perf-web-runtime.mjs +++ b/tooling/scripts/perf-web-runtime.mjs @@ -10,10 +10,18 @@ import { fileURLToPath } from 'node:url' import WebSocket from 'ws' import { withGoEnv } from './go-env.mjs' +import { webDistLockEnv, withWebDistLock } from './web-dist-lock.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') -const serverRoot = resolve(repoRoot, 'apps/server') +// The Go server lives in ZenNotes/znserver. A perf run measures the local web +// bundle, so it needs a server that embeds it: either a checkout in +// ZENNOTES_SERVER_DIR (web dist synced in, then `go build -tags=embed_web`) or +// a binary the caller built the same way. The pinned release binary embeds the +// pinned artifact instead, so it is never picked up silently; pass it through +// ZEN_PERF_WEB_SERVER_BINARY=$(npm run -s server:binary) to measure a release. +const serverCheckout = process.env.ZENNOTES_SERVER_DIR?.trim() + ? resolve(process.env.ZENNOTES_SERVER_DIR.trim()) : null const webDistIndex = resolve(repoRoot, 'apps/web/dist/index.html') const syncWebDistScript = resolve(repoRoot, 'tooling/scripts/sync-web-dist.mjs') @@ -21,6 +29,8 @@ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' const noteCount = parsePositiveInt(process.env.ZEN_PERF_WEB_NOTES, 5000) const enforceBudgets = process.env.ZEN_PERF_ENFORCE === '1' const skipWebBuild = process.env.ZEN_PERF_SKIP_WEB_BUILD === '1' +const prebuiltServerEnv = process.env.ZEN_PERF_WEB_SERVER_BINARY?.trim() || process.env.ZENNOTES_SERVER_BINARY?.trim() +const prebuiltServer = prebuiltServerEnv ? resolve(prebuiltServerEnv) : null const externalVaultRoot = externalVaultRootFromEnv('ZEN_PERF_WEB_VAULT_ROOT') const configuredTempRoot = process.env.ZEN_PERF_WEB_TEMP_ROOT?.trim() ? resolve(process.env.ZEN_PERF_WEB_TEMP_ROOT.trim()) @@ -251,17 +261,17 @@ function appendBounded(buffer, chunk, maxLength = 12000) { return next.length > maxLength ? next.slice(next.length - maxLength) : next } -function startGoServer({ vaultRoot, bind, serverBinary, configPath, disablePersistedMetaCache }) { +function startGoServer({ vaultRoot, bind, serverBinary, configPath, cwd, disablePersistedMetaCache }) { const env = { ...process.env, ZENNOTES_BIND: bind, ZENNOTES_CONFIG_PATH: configPath, - ZENNOTES_VAULT_PATH: vaultRoot, + ZENNOTES_DEFAULT_VAULT_PATH: vaultRoot, ZENNOTES_ALLOW_INSECURE_NOAUTH: '1', ...(disablePersistedMetaCache ? { ZEN_PERF_DISABLE_PERSISTED_META_CACHE: '1' } : {}) } const child = spawn(serverBinary, [], { - cwd: serverRoot, + cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }) @@ -444,19 +454,20 @@ async function waitForExpression(client, expression, timeoutMs, label) { throw new Error(`Timed out waiting for ${label}: ${lastError?.message ?? 'condition not met'}`) } -async function prepareWebDist() { +async function prepareWebDist(env) { if (!skipWebBuild || !(await fileExists(webDistIndex))) { await run(npmCommand, ['run', 'build:nocheck', '--workspace', '@zennotes/web'], { - shell: process.platform === 'win32' + shell: process.platform === 'win32', + env }) } - await run(process.execPath, [syncWebDistScript]) + await run(process.execPath, [syncWebDistScript, join(serverCheckout, 'web/dist')], { env }) } -async function buildGoServer(outputPath) { - await run('go', ['build', '-trimpath', '-o', outputPath, './cmd/zennotes-server'], { - cwd: serverRoot, - env: withGoEnv() +async function buildGoServer(outputPath, env) { + await run('go', ['build', '-tags=embed_web', '-trimpath', '-o', outputPath, './cmd/zennotes-server'], { + cwd: serverCheckout, + env: withGoEnv(env) }) } @@ -586,13 +597,19 @@ async function stopChild(child) { } async function main() { - await prepareWebDist() - + if (!prebuiltServer && !serverCheckout) { + throw new Error( + 'perf:web-runtime needs a server that embeds the local web bundle: set ZENNOTES_SERVER_DIR to a ' + + 'ZenNotes/znserver checkout, or ZEN_PERF_WEB_SERVER_BINARY to a server built with -tags=embed_web ' + + '(use $(npm run -s server:binary) to measure the pinned release instead)' + ) + } + if (prebuiltServer && serverCheckout) throw new Error('Choose ZEN_PERF_WEB_SERVER_BINARY or ZENNOTES_SERVER_DIR, not both') const tempRoot = configuredTempRoot ?? await mkdtemp(join(tmpdir(), 'zennotes-web-perf-')) if (configuredTempRoot) await mkdir(tempRoot, { recursive: true }) const vaultRoot = externalVaultRoot ?? join(tempRoot, 'vault') const chromeProfile = join(tempRoot, 'chrome-profile') - const serverBinary = join( + const serverBinary = prebuiltServer ?? join( tempRoot, process.platform === 'win32' ? 'zennotes-server.exe' : 'zennotes-server' ) @@ -614,12 +631,18 @@ async function main() { } const seedMs = round(performance.now() - seedStartedAt) - await buildGoServer(serverBinary) + if (prebuiltServer) await access(serverBinary, constants.X_OK) + else await withWebDistLock(async (lock) => { + const env = webDistLockEnv(lock) + await prepareWebDist(env) + await buildGoServer(serverBinary, env) + }) server = startGoServer({ vaultRoot, bind: `127.0.0.1:${serverPort}`, serverBinary, configPath: join(tempRoot, 'zennotes-perf-server.json'), + cwd: tempRoot, disablePersistedMetaCache: Boolean(externalVaultRoot) }) await waitForHttpOk(`http://127.0.0.1:${serverPort}/healthz`, 20000) @@ -708,6 +731,12 @@ async function main() { 'workspace ready' ) + await waitForExpression(client, `(() => { + const skip = [...document.querySelectorAll('button')].find((button) => button.textContent.trim() === 'Skip setup'); + skip?.click(); + return Boolean(document.querySelector('[data-sidebar-type], [data-notelist-path]')); + })()`, 10000, 'web navigation after first-run setup') + const inboxExpansion = await evaluate( client, `(async () => { diff --git a/tooling/scripts/prepare-boundary-release.mjs b/tooling/scripts/prepare-boundary-release.mjs new file mode 100644 index 00000000..ee6e7790 --- /dev/null +++ b/tooling/scripts/prepare-boundary-release.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { packAppCore } from './pack-app-core.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const target = process.argv[2] +assert.ok(['core', 'web', 'viewer'].includes(target), 'Expected core, web or viewer') +const local = process.argv.includes('--allow-dirty') +const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim().length > 0 +assert.ok(local || !dirty, 'Release preparation requires an approved clean source commit; use --allow-dirty only for a local rehearsal') +const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim() +if (process.env.APPROVED_SOURCE) assert.equal(commit, process.env.APPROVED_SOURCE, 'Source differs from the approved commit') +let version, entries +if (target === 'core') { + const core = await packAppCore() + version = core.version + entries = [core, ...core.dependencies].map(manifest => ({ archive: manifest.archive, manifest, filename: manifest.file })) +} else { + const script = target === 'web' ? 'pack-web-artifact.mjs' : 'pack-share-viewer.mjs' + const log = execFileSync(process.execPath, [join(root, 'tooling/scripts', script)], { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }) + const result = JSON.parse(log.slice(log.lastIndexOf('\n{'))) + const manifest = JSON.parse(await readFile(result.manifest, 'utf8')) + version = result.version + entries = [{ archive: result.archive, manifest, filename: manifest.archive.file }] +} +const output = join(root, 'dist/boundary-release', `${target}-${version}`) +await mkdir(output, { recursive: true }) +// Runtime tests use absolute paths; public provenance must remain portable. +function portable(value) { + if (Array.isArray(value)) return value.map(portable) + if (!value || typeof value !== 'object') return value + return Object.fromEntries(Object.entries(value).filter(([key, val]) => !(key === 'archive' && typeof val === 'string')).map(([key, val]) => [key, portable(val)])) +} +const files = [] +for (const entry of entries) { + const bytes = await readFile(entry.archive) + assert.equal(createHash('sha256').update(bytes).digest('hex'), entry.manifest.sha256 ?? entry.manifest.archive.sha256) + assert.equal(entry.manifest.sourceCommit ?? entry.manifest.source.commit, commit) + assert.ok(local || !(entry.manifest.workingTreeDirty ?? entry.manifest.source?.dirty), 'Dirty artifacts cannot be released') + await copyFile(entry.archive, join(output, entry.filename)) + await writeFile(join(output, `${entry.filename}.json`), JSON.stringify(portable(entry.manifest), null, 2) + '\n') + files.push(entry.filename, `${entry.filename}.json`) +} +const release = { target, tag: `${target}-${version}`, sourceCommit: commit, localCandidate: local, files } +await writeFile(join(output, 'release.json'), JSON.stringify(release, null, 2) + '\n') +if (process.env.GITHUB_OUTPUT) await writeFile(process.env.GITHUB_OUTPUT, `directory=${output}\ntag=${release.tag}\n`, { flag: 'a' }) +console.log(JSON.stringify({ output, ...release }, null, 2)) diff --git a/tooling/scripts/prepare-server-web-dist.mjs b/tooling/scripts/prepare-server-web-dist.mjs deleted file mode 100644 index fcb36b62..00000000 --- a/tooling/scripts/prepare-server-web-dist.mjs +++ /dev/null @@ -1,57 +0,0 @@ -import { access } from 'node:fs/promises' -import { constants } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawn } from 'node:child_process' - -import { webDistLockEnv, withWebDistLock } from './web-dist-lock.mjs' - -const scriptDir = dirname(fileURLToPath(import.meta.url)) -const repoRoot = resolve(scriptDir, '..', '..') -const webDistIndex = resolve(repoRoot, 'apps/web/dist/index.html') -const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' - -async function fileExists(path) { - try { - await access(path, constants.F_OK) - return true - } catch { - return false - } -} - -function run(command, args, cwd = repoRoot, options = {}) { - const shell = options.shell ?? false - return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(command, args, { - cwd, - stdio: 'inherit', - shell, - env: options.env ?? process.env - }) - child.on('exit', (code) => { - if (code === 0) { - resolvePromise() - return - } - rejectPromise(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 'unknown'}`)) - }) - child.on('error', rejectPromise) - }) -} - -// The build and the sync run under one lock. Two turbo tasks arriving at once -// would otherwise both spawn `vite build` into apps/web/dist, and vite empties -// its outDir before writing, so the loser could stage a half-written tree into -// the server bundle. The second one through finds index.html already there and -// only syncs. The child processes inherit the lock rather than wait on it. -await withWebDistLock(async (lock) => { - const env = webDistLockEnv(lock) - if (!(await fileExists(webDistIndex))) { - await run(npmCommand, ['run', 'build', '--workspace', '@zennotes/web'], repoRoot, { - shell: process.platform === 'win32', - env - }) - } - await run(process.execPath, [resolve(repoRoot, 'tooling/scripts/sync-web-dist.mjs')], repoRoot, { env }) -}) diff --git a/tooling/scripts/run-go-server-dev.mjs b/tooling/scripts/run-go-server-dev.mjs deleted file mode 100644 index 3667c982..00000000 --- a/tooling/scripts/run-go-server-dev.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawn } from 'node:child_process' - -import { withGoEnv } from './go-env.mjs' - -const scriptDir = dirname(fileURLToPath(import.meta.url)) -const repoRoot = resolve(scriptDir, '..', '..') -const serverRoot = resolve(repoRoot, 'apps/server') - -const child = spawn('go', ['run', './cmd/zennotes-server'], { - cwd: serverRoot, - env: withGoEnv({ - ZENNOTES_DEV: '1' - }), - stdio: 'inherit' -}) - -child.on('exit', (code, signal) => { - if (signal) { - process.kill(process.pid, signal) - return - } - process.exit(code ?? 1) -}) - -child.on('error', (error) => { - console.error(error) - process.exit(1) -}) diff --git a/tooling/scripts/run-go-server-test.mjs b/tooling/scripts/run-go-server-test.mjs deleted file mode 100644 index 21a4db9e..00000000 --- a/tooling/scripts/run-go-server-test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawn } from 'node:child_process' - -import { withGoEnv } from './go-env.mjs' - -const scriptDir = dirname(fileURLToPath(import.meta.url)) -const repoRoot = resolve(scriptDir, '..', '..') -const serverRoot = resolve(repoRoot, 'apps/server') -const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' - -function run(command, args, cwd = repoRoot, options = {}) { - const shell = options.shell ?? false - return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(command, args, { - cwd, - env: options.env ?? process.env, - stdio: 'inherit', - shell, - }) - - child.on('exit', (code) => { - if (code === 0) { - resolvePromise() - return - } - rejectPromise(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 'unknown'}`)) - }) - - child.on('error', rejectPromise) - }) -} - -await run(npmCommand, ['run', 'prepare-web'], serverRoot, { - shell: process.platform === 'win32', -}) - -await run('go', ['test', './...'], serverRoot, { - env: withGoEnv(), -}) diff --git a/tooling/scripts/run-server-dev.mjs b/tooling/scripts/run-server-dev.mjs new file mode 100644 index 00000000..f21c7330 --- /dev/null +++ b/tooling/scripts/run-server-dev.mjs @@ -0,0 +1,26 @@ +import { spawn } from 'node:child_process' +import { resolve } from 'node:path' +import { resolveServerBinary } from './server-binary.mjs' + +// dev:server and dev:web-stack start the server this way. A ZenNotes/znserver +// checkout in ZENNOTES_SERVER_DIR keeps the edit-and-restart loop through +// `go run`; an explicit ZENNOTES_SERVER_BINARY runs as is; otherwise the pinned +// published release runs, so browser work needs neither Go nor a checkout. +const checkout = process.env.ZENNOTES_SERVER_DIR?.trim() +const explicit = process.env.ZENNOTES_SERVER_BINARY?.trim() +if (checkout && explicit) throw new Error('Choose ZENNOTES_SERVER_BINARY or ZENNOTES_SERVER_DIR, not both') +const env = { ...process.env, ZENNOTES_DEV: '1' } +const child = checkout + ? spawn('go', ['run', './cmd/zennotes-server'], { cwd: resolve(checkout), env, stdio: 'inherit' }) + : spawn(await resolveServerBinary(), [], { env, stdio: 'inherit' }) +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + process.exit(code ?? 1) +}) +child.on('error', (error) => { + console.error(error) + process.exit(1) +}) diff --git a/tooling/scripts/server-binary.mjs b/tooling/scripts/server-binary.mjs new file mode 100644 index 00000000..b1cd5484 --- /dev/null +++ b/tooling/scripts/server-binary.mjs @@ -0,0 +1,82 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { access, chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const pinPath = join(repoRoot, 'tooling/server-release.json') + +// The Go server lives in ZenNotes/znserver. Anything here that needs a running +// server (the browser harness, perf runs, dev:web-stack) takes, in order, an +// explicit binary, an explicit checkout built with Go, or the pinned published +// release verified against the SHA-256 recorded in tooling/server-release.json. +// Nothing in this repository builds the server from source any more. +export async function resolveServerBinary({ log = (line) => process.stderr.write(`${line}\n`) } = {}) { + const explicit = process.env.ZENNOTES_SERVER_BINARY?.trim() + const checkout = process.env.ZENNOTES_SERVER_DIR?.trim() + if (explicit && checkout) throw new Error('Choose ZENNOTES_SERVER_BINARY or ZENNOTES_SERVER_DIR, not both') + if (explicit) { + await access(explicit, constants.X_OK) + return resolve(explicit) + } + if (checkout) return buildFromCheckout(resolve(checkout), log) + return downloadPinned(log) +} + +export async function readServerPin() { + return JSON.parse(await readFile(pinPath, 'utf8')) +} + +export function pinnedAssetKey() { + return `${process.platform}-${process.arch}` +} + +async function buildFromCheckout(dir, log) { + const out = join(dir, 'bin', process.platform === 'win32' ? 'zennotes-server.exe' : 'zennotes-server') + log(`[server] building ${out} from ${dir}`) + execFileSync('go', ['build', '-trimpath', '-o', out, './cmd/zennotes-server'], { cwd: dir, stdio: 'inherit' }) + return out +} + +async function sha256Of(path) { + try { + return createHash('sha256').update(await readFile(path)).digest('hex') + } catch (error) { + if (error.code === 'ENOENT') return null + throw error + } +} + +async function downloadPinned(log) { + const pin = await readServerPin() + const key = pinnedAssetKey() + const asset = pin.assets[key] + if (!asset) throw new Error(`No pinned server binary for ${key}; set ZENNOTES_SERVER_BINARY or ZENNOTES_SERVER_DIR`) + const directory = join(repoRoot, 'dist/server-binaries', pin.version) + const target = join(directory, asset.file) + if ((await sha256Of(target)) === asset.sha256) { + await chmod(target, 0o755) + return target + } + const url = `https://github.com/${pin.repository}/releases/download/${pin.version}/${asset.file}` + log(`[server] downloading ${url}`) + const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(180000) }) + if (!response.ok) throw new Error(`Server download failed: HTTP ${response.status} for ${url}`) + const bytes = Buffer.from(await response.arrayBuffer()) + const digest = createHash('sha256').update(bytes).digest('hex') + if (digest !== asset.sha256) { + throw new Error(`Server download checksum mismatch for ${asset.file}: expected ${asset.sha256}, got ${digest}`) + } + await mkdir(directory, { recursive: true }) + const temp = `${target}.${process.pid}.tmp` + await writeFile(temp, bytes, { mode: 0o755 }) + await rename(temp, target) + await chmod(target, 0o755) + return target +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.stdout.write(`${await resolveServerBinary()}\n`) +} diff --git a/tooling/scripts/sync-contract-fixtures.mjs b/tooling/scripts/sync-contract-fixtures.mjs new file mode 100644 index 00000000..b847c7b9 --- /dev/null +++ b/tooling/scripts/sync-contract-fixtures.mjs @@ -0,0 +1,46 @@ +import { createHash } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +// The Go consumer of these fixtures is ZenNotes/znserver. Point at a checkout +// with ZENNOTES_SERVER_DIR (or the first non-flag argument); --write copies the +// fixtures and their provenance in, and without it the copies are compared. +const serverDir = process.argv.slice(2).find((arg) => !arg.startsWith('--')) ?? process.env.ZENNOTES_SERVER_DIR +if (!serverDir) throw new Error('Pass a ZenNotes/znserver checkout path or set ZENNOTES_SERVER_DIR') +const fixtures = [ + ['task-roundtrip.json', 'vault'], + ['self-hosted-http.json', 'httpserver'] +] +for (const [name, consumer] of fixtures) { + const source = `packages/bridge-contract/fixtures/${name}` + const target = resolve(serverDir, `internal/${consumer}/testdata/${name}`) + const bytes = await readFile(resolve(root, source)) + const provenance = + JSON.stringify( + { + sourceRepository: 'https://github.com/ZenNotes/zennotes', + sourcePath: source, + sha256: createHash('sha256').update(bytes).digest('hex') + }, + null, + 2 + ) + '\n' + + for (const [path, content] of [ + [target, bytes], + [`${target}.source.json`, Buffer.from(provenance)] + ]) { + if (process.argv.includes('--write')) { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content) + } else { + const current = await readFile(path) + if (!current.equals(content)) { + throw new Error(`Contract fixture differs: ${path}. Run npm run sync:contract-fixtures -- .`) + } + } + } +} +process.stdout.write(`Go fixtures in ${serverDir} match the shared contract bytes.\n`) diff --git a/tooling/scripts/sync-web-dist.mjs b/tooling/scripts/sync-web-dist.mjs index ba1d1b44..5d0058dd 100644 --- a/tooling/scripts/sync-web-dist.mjs +++ b/tooling/scripts/sync-web-dist.mjs @@ -8,10 +8,17 @@ import { withWebDistLock } from './web-dist-lock.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') const webDist = resolve(repoRoot, 'apps/web/dist') -const serverDist = resolve(repoRoot, 'apps/server/web/dist') +// The server no longer lives here. The destination is a ZenNotes/znserver +// checkout's web/dist, given as the first argument or through ZENNOTES_SERVER_DIR. +const destinationArg = process.argv[2]?.trim() +const checkout = process.env.ZENNOTES_SERVER_DIR?.trim() +const serverDist = destinationArg ? resolve(destinationArg) : checkout ? resolve(checkout, 'web/dist') : null +if (!serverDist) { + throw new Error('sync-web-dist needs a destination: pass /web/dist or set ZENNOTES_SERVER_DIR to a ZenNotes/znserver checkout') +} -// `apps/server` runs `prepare-web` from BOTH `typecheck` and `test:run`, and -// turbo schedules those two tasks concurrently. A plain `rm` followed by `cp` +// Two producers can run this concurrently (a perf run and a manual sync, or +// two turbo tasks in the checkout). A plain `rm` followed by `cp` // therefore races: the second process deletes the directory while the first is // still copying into it, and the first dies with ENOENT partway through. It // surfaces as `turbo run typecheck test:run` failing on a machine where each @@ -109,8 +116,8 @@ async function main() { if (source === null) { throw new Error(`no web bundle at ${webDist}; run \`npm run build --workspace @zennotes/web\` first`) } - // Identical trees are the steady state across repeated turbo runs. Skipping - // the swap keeps `apps/server/web/dist` continuously present for go:embed. + // Identical trees are the steady state across repeated runs. Skipping the + // swap keeps the checkout's web/dist continuously present for go:embed. if (source === (await treeSignature(serverDist))) return const stage = `${serverDist}.stage-${process.pid}` diff --git a/tooling/scripts/terminal-artifact.mjs b/tooling/scripts/terminal-artifact.mjs new file mode 100644 index 00000000..6adf9ad6 --- /dev/null +++ b/tooling/scripts/terminal-artifact.mjs @@ -0,0 +1,297 @@ +import { createHash, randomUUID } from 'node:crypto' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { + mkdir, + mkdtemp, + readFile, + writeFile, + rm, + rename, +} from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' + +const exec = promisify(execFile) +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') +const versionPattern = /^[a-zA-Z0-9][a-zA-Z0-9.+-]{0,99}$/ +const supported = (platform, arch) => + ['darwin', 'linux'].includes(platform) && ['x64', 'arm64'].includes(arch) + +export function inspectTerminalBinary(bytes, platform, arch) { + if (!supported(platform, arch)) + throw new Error( + `Unsupported terminal platform/architecture: ${platform}/${arch}`, + ) + if (bytes.length < 32) + throw new Error('Terminal artifact is not a native executable.') + if (platform === 'darwin') { + if (bytes.readUInt32LE(0) !== 0xfeedfacf) + throw new Error('Terminal artifact is not a Mach-O executable.') + if (bytes.readUInt32LE(4) !== (arch === 'arm64' ? 0x0100000c : 0x01000007)) + throw new Error('Terminal architecture mismatch.') + } else { + if ( + !bytes.subarray(0, 6).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1])) + ) + throw new Error('Terminal artifact is not an ELF64 executable.') + if (bytes.readUInt16LE(18) !== (arch === 'arm64' ? 183 : 62)) + throw new Error('Terminal architecture mismatch.') + } +} + +export function validateTerminalRelease(manifest, platform, arch) { + if (manifest?.schemaVersion !== 1) + throw new Error('Unsupported terminal release manifest.') + if (manifest.release === null) return null + const pin = manifest.release + if ( + pin?.repository !== 'ZenNotes/tui' || + pin.protocol !== 1 || + typeof pin.version !== 'string' || + !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(pin.version) || + !/^[a-f0-9]{40}$/.test(pin.commit ?? '') + ) + throw new Error( + 'Terminal release requires a version, source commit, and integration protocol.', + ) + const artifact = pin.artifacts?.[`${platform}-${arch}`] + if (!artifact || !/^[a-f0-9]{64}$/.test(artifact.sha256 ?? '')) + throw new Error( + `Missing verified terminal artifact for ${platform}-${arch}.`, + ) + const goArch = arch === 'x64' ? 'amd64' : arch + const expected = `https://github.com/ZenNotes/tui/releases/download/v${pin.version}/zn_${pin.version}_${platform}_${goArch}.tar.gz` + if (artifact.url !== expected) + throw new Error( + 'Terminal artifact URL must name the pinned GitHub release archive.', + ) + return { ...pin, artifact } +} + +async function checkProbe(binary, manifest) { + const { stdout } = await exec(binary, ['--desktop-integration'], { + timeout: 10000, + maxBuffer: 65536, + }) + let result + try { + result = JSON.parse(stdout) + } catch { + throw new Error('Terminal integration probe returned invalid JSON.') + } + if (result.protocol !== 1 || result.version !== manifest.version) + throw new Error('Terminal integration probe does not match the artifact.') +} + +async function installStage(output, bytes, license, manifest, probe) { + inspectTerminalBinary(bytes, manifest.platform, manifest.arch) + if ( + manifest.schemaVersion !== 1 || + manifest.protocol !== 1 || + !versionPattern.test(manifest.version ?? '') + ) + throw new Error('Invalid terminal integration manifest.') + await mkdir(dirname(output), { recursive: true }) + const stage = await mkdtemp(`${output}.stage-`) + const retired = `${output}.retired-${randomUUID()}` + let replaced = false + try { + const binary = join(stage, 'zn') + await writeFile(binary, bytes, { mode: 0o755 }) + await writeFile(join(stage, 'LICENSE'), license) + await writeFile( + join(stage, 'manifest.json'), + JSON.stringify({ ...manifest, binarySha256: hash(bytes) }, null, 2) + + '\n', + ) + if ( + probe && + manifest.platform === process.platform && + manifest.arch === process.arch + ) + await checkProbe(binary, manifest) + try { + await rename(output, retired) + replaced = true + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + try { + await rename(stage, output) + } catch (error) { + if (replaced) await rename(retired, output) + throw error + } + if (replaced) await rm(retired, { recursive: true, force: true }) + return manifest + } finally { + await rm(stage, { recursive: true, force: true }) + } +} + +export async function stageTerminalArtifact({ + platform = process.platform, + arch = process.arch, + manifestPath = join(root, 'apps/desktop/terminal-release.json'), + output, + localDirectory, + allowLocal = false, + probe = true, + fetchImpl = fetch, +} = {}) { + if (!supported(platform, arch)) + throw new Error( + `Unsupported terminal platform/architecture: ${platform}/${arch}`, + ) + if (!output) + throw new Error('A terminal artifact output directory is required.') + if (localDirectory) { + if (!allowLocal) + throw new Error('Local terminal artifacts require explicit opt-in.') + const source = join(localDirectory, `${platform}-${arch}`) + const manifest = JSON.parse( + await readFile(join(source, 'manifest.json'), 'utf8'), + ) + if ( + !manifest.local || + manifest.platform !== platform || + manifest.arch !== arch + ) + throw new Error( + 'Local terminal artifact platform or architecture mismatch.', + ) + const bytes = await readFile(join(source, 'zn')) + if (hash(bytes) !== manifest.binarySha256) + throw new Error('Local terminal artifact checksum mismatch.') + return installStage( + output, + bytes, + await readFile(join(source, 'LICENSE')), + manifest, + probe, + ) + } + const pin = validateTerminalRelease( + JSON.parse(await readFile(manifestPath, 'utf8')), + platform, + arch, + ) + if (!pin) { + // A reused packaging directory must not retain a development candidate. + await rm(output, { recursive: true, force: true }) + return null + } + const response = await fetchImpl(pin.artifact.url, { + signal: AbortSignal.timeout(120000), + }) + if (!response.ok) + throw new Error( + `Terminal artifact download failed: HTTP ${response.status}.`, + ) + const chunks = [] + let size = 0 + for await (const chunk of response.body) { + size += chunk.length + if (size > 96 * 1024 * 1024) + throw new Error('Terminal artifact exceeds the download limit.') + chunks.push(chunk) + } + const archive = Buffer.concat(chunks) + if (hash(archive) !== pin.artifact.sha256) + throw new Error('Terminal release checksum mismatch.') + const scratch = await mkdtemp(join(tmpdir(), 'zn-terminal-artifact-')) + try { + const archivePath = join(scratch, 'release.tar.gz') + await writeFile(archivePath, archive) + // Read only the named files to stdout. Archive paths are never extracted + // onto the build filesystem, including links or traversal entries. + const unpack = async (name) => + ( + await exec('tar', ['-xOzf', archivePath, name], { + encoding: 'buffer', + maxBuffer: 128 * 1024 * 1024, + }) + ).stdout + const bytes = await unpack('zn'), + license = await unpack('LICENSE') + return installStage( + output, + bytes, + license, + { + schemaVersion: 1, + protocol: pin.protocol, + version: pin.version, + platform, + arch, + source: { repository: pin.repository, commit: pin.commit }, + archiveSha256: pin.artifact.sha256, + local: false, + }, + probe, + ) + } finally { + await rm(scratch, { recursive: true, force: true }) + } +} + +async function main() { + const args = process.argv.slice(2) + const option = (name) => { + const i = args.indexOf(name) + return i < 0 ? undefined : args[i + 1] + } + const platform = option('--platform') ?? process.platform, + arch = option('--arch') ?? process.arch + const output = resolve( + option('--output') ?? + join(root, 'apps/desktop/build/terminal', `${platform}-${arch}`), + ) + const binary = option('--local-binary') + if (binary) { + const license = option('--license') + if (!license) + throw new Error( + 'A local candidate requires --license from the TUI repository.', + ) + if (platform !== process.platform || arch !== process.arch) + throw new Error( + 'Local candidates must be staged on their native host for the integration probe.', + ) + const bytes = await readFile(binary) + inspectTerminalBinary(bytes, platform, arch) + const { stdout } = await exec(binary, ['--desktop-integration'], { + timeout: 10000, + }) + const integration = JSON.parse(stdout) + await installStage( + output, + bytes, + await readFile(license), + { + schemaVersion: 1, + protocol: integration.protocol, + version: integration.version, + platform, + arch, + local: true, + }, + true, + ) + } else { + await stageTerminalArtifact({ platform, arch, output }) + } + console.log(output) +} +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main().catch((error) => { + console.error(error.message) + process.exitCode = 1 + }) +} diff --git a/tooling/scripts/terminal-artifact.test.mjs b/tooling/scripts/terminal-artifact.test.mjs new file mode 100644 index 00000000..90be3512 --- /dev/null +++ b/tooling/scripts/terminal-artifact.test.mjs @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHash } from 'node:crypto' +import { + inspectTerminalBinary, + stageTerminalArtifact, + validateTerminalRelease, +} from './terminal-artifact.mjs' + +function executable(platform, arch) { + const b = Buffer.alloc(64) + if (platform === 'darwin') { + b.writeUInt32LE(0xfeedfacf) + b.writeUInt32LE(arch === 'arm64' ? 0x0100000c : 0x01000007, 4) + } else { + b.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]) + b.writeUInt16LE(arch === 'arm64' ? 183 : 62, 18) + } + return b +} +test('native artifact architecture must match the desktop target', () => { + for (const platform of ['darwin', 'linux']) + for (const arch of ['x64', 'arm64']) { + assert.doesNotThrow(() => + inspectTerminalBinary(executable(platform, arch), platform, arch), + ) + assert.throws( + () => + inspectTerminalBinary( + executable(platform, arch), + platform, + arch === 'x64' ? 'arm64' : 'x64', + ), + /architecture/, + ) + } + assert.throws( + () => inspectTerminalBinary(Buffer.from('#!/bin/sh\n'), 'darwin', 'arm64'), + /executable/, + ) +}) +test('release pins require immutable version, source commit, and checksums', () => { + assert.equal( + validateTerminalRelease( + { schemaVersion: 1, release: null }, + 'darwin', + 'arm64', + ), + null, + ) + const valid = { + schemaVersion: 1, + release: { + repository: 'ZenNotes/tui', + version: '1.0.0', + commit: 'a'.repeat(40), + protocol: 1, + artifacts: { + 'darwin-arm64': { + url: 'https://github.com/ZenNotes/tui/releases/download/v1.0.0/zn_1.0.0_darwin_arm64.tar.gz', + sha256: 'b'.repeat(64), + }, + }, + }, + } + assert.equal( + validateTerminalRelease(valid, 'darwin', 'arm64').version, + '1.0.0', + ) + assert.throws( + () => validateTerminalRelease(valid, 'linux', 'x64'), + /artifact/, + ) + const bad = structuredClone(valid) + bad.release.artifacts['darwin-arm64'].url = + 'https://github.com/ZenNotes/tui/releases/latest/download/zn.tar.gz' + assert.throws(() => validateTerminalRelease(bad, 'darwin', 'arm64'), /URL/) +}) +test('local candidates require opt-in and checksum verification without replacing an existing stage on failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'zn-artifact-')) + try { + const local = join(root, 'local'), + staged = join(root, 'output') + const target = join(local, 'linux-arm64') + await mkdir(target, { recursive: true }) + const bytes = executable('linux', 'arm64') + await writeFile(join(target, 'zn'), bytes) + await writeFile(join(target, 'LICENSE'), 'MIT\n') + const manifest = { + schemaVersion: 1, + protocol: 1, + version: '1.0.0-local', + platform: 'linux', + arch: 'arm64', + local: true, + binarySha256: createHash('sha256').update(bytes).digest('hex'), + } + await writeFile(join(target, 'manifest.json'), JSON.stringify(manifest)) + const options = { + platform: 'linux', + arch: 'arm64', + localDirectory: local, + output: staged, + probe: false, + } + await assert.rejects(stageTerminalArtifact(options), /local/i) + await stageTerminalArtifact({ ...options, allowLocal: true }) + assert.deepEqual(await readFile(join(staged, 'zn')), bytes) + await writeFile( + join(target, 'zn'), + Buffer.concat([bytes, Buffer.from('changed')]), + ) + await assert.rejects( + stageTerminalArtifact({ ...options, allowLocal: true }), + /checksum/i, + ) + assert.deepEqual(await readFile(join(staged, 'zn')), bytes) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('a disabled release removes a previously staged candidate', async () => { + const root = await mkdtemp(join(tmpdir(), 'zn-artifact-disabled-')) + try { + const output = join(root, 'output'), + manifestPath = join(root, 'release.json') + await mkdir(output) + await writeFile(join(output, 'zn'), 'stale local candidate') + await writeFile( + manifestPath, + JSON.stringify({ schemaVersion: 1, release: null }), + ) + assert.equal(await stageTerminalArtifact({ output, manifestPath }), null) + await assert.rejects(readFile(join(output, 'zn')), { code: 'ENOENT' }) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('a pinned release verifies its archive before replacing the previous executable', async () => { + const { execFileSync } = await import('node:child_process') + const root = await mkdtemp(join(tmpdir(), 'zn-artifact-release-')) + try { + const source = join(root, 'source'), + output = join(root, 'output'), + manifestPath = join(root, 'release.json') + await mkdir(source) + const bytes = executable('linux', 'x64') + await writeFile(join(source, 'zn'), bytes) + await writeFile(join(source, 'LICENSE'), 'MIT\n') + const archive = execFileSync('tar', [ + '-czf', + '-', + '-C', + source, + 'zn', + 'LICENSE', + ]) + const url = + 'https://github.com/ZenNotes/tui/releases/download/v1.0.0/zn_1.0.0_linux_amd64.tar.gz' + const release = { + schemaVersion: 1, + release: { + repository: 'ZenNotes/tui', + version: '1.0.0', + commit: 'a'.repeat(40), + protocol: 1, + artifacts: { + 'linux-x64': { + url, + sha256: createHash('sha256').update(archive).digest('hex'), + }, + }, + }, + } + await writeFile(manifestPath, JSON.stringify(release)) + let downloaded = archive + const options = { + platform: 'linux', + arch: 'x64', + output, + manifestPath, + probe: false, + fetchImpl: async (requested) => { + assert.equal(requested, url) + return new Response(downloaded) + }, + } + await stageTerminalArtifact(options) + assert.deepEqual(await readFile(join(output, 'zn')), bytes) + assert.equal(await readFile(join(output, 'LICENSE'), 'utf8'), 'MIT\n') + const installed = JSON.parse( + await readFile(join(output, 'manifest.json'), 'utf8'), + ) + assert.equal(installed.local, false) + assert.equal(installed.source.commit, release.release.commit) + downloaded = Buffer.from('corrupt download') + await assert.rejects(stageTerminalArtifact(options), /checksum/i) + assert.deepEqual(await readFile(join(output, 'zn')), bytes) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/tooling/scripts/terminal-launcher.test.mjs b/tooling/scripts/terminal-launcher.test.mjs new file mode 100644 index 00000000..a0b7bd5b --- /dev/null +++ b/tooling/scripts/terminal-launcher.test.mjs @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + chmod, + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import test from 'node:test' + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'zennotes terminal & ')) + t.after(() => rm(root, { recursive: true, force: true })) + const resources = join(root, 'ZenNotes.app/Contents/Resources') + const electron = join(root, 'ZenNotes.app/Contents/MacOS/ZenNotes') + const userData = join(root, 'user data') + const trace = join(root, 'trace') + const command = join(root, 'bin/zn') + const cliJS = join(resources, 'cli.js') + await Promise.all( + [ + resources, + dirname(electron), + userData, + trace, + dirname(command), + join(root, 'home'), + join(root, 'config'), + ].map((path) => mkdir(path, { recursive: true })), + ) + await copyFile( + new URL('../../apps/desktop/build/zen', import.meta.url), + join(resources, 'zen'), + ) + await chmod(join(resources, 'zen'), 0o755) + await symlink(join(resources, 'zen'), command) + await writeFile( + cliJS, + '// The fake Electron executable records this entry point.\n', + ) + + async function executable(path, engine, exitCode = 0) { + await mkdir(dirname(path), { recursive: true }) + await writeFile( + path, + `#!/bin/sh +set -eu +printf '%s\\n' '${engine}' >> "$TEST_TRACE_DIR/calls" +printf '%s\\0' "\${ZENNOTES_WORKSPACE_SOURCE-}" "\${ELECTRON_RUN_AS_NODE-}" "$@" > "$TEST_TRACE_DIR/${engine}.args" +cat +printf '%s' "\${TEST_STDERR-}" >&2 +exit ${exitCode} +`, + ) + await chmod(path, 0o755) + } + await executable(electron, 'legacy') + + function run(args = [], { input = '', env = {} } = {}) { + const result = spawnSync(command, args, { + cwd: root, + input, + timeout: 5000, + env: { + PATH: '/usr/bin:/bin', + HOME: join(root, 'home'), + XDG_CONFIG_HOME: join(root, 'config'), + ZENNOTES_CONFIG_DIR: join(root, 'config'), + ZENNOTES_USER_DATA_PATH: userData, + TEST_TRACE_DIR: trace, + ...env, + }, + }) + assert.ifError(result.error) + assert.equal(result.signal, null) + return result + } + + async function invocation(engine) { + const fields = ( + await readFile(join(trace, `${engine}.args`), 'utf8') + ).split('\0') + assert.equal(fields.pop(), '') + return { + workspaceSource: fields[0], + electronRunAsNode: fields[1], + args: fields.slice(2), + } + } + + return { + resources, + electron, + userData, + cliJS, + executable, + run, + invocation, + calls: async () => + (await readFile(join(trace, 'calls'), 'utf8')).trimEnd().split('\n'), + } +} + +test('an existing desktop shortcut runs the activated Go CLI without Electron or cli.js', async (t) => { + const app = await fixture(t) + await app.executable(join(app.userData, 'cli/zn'), 'managed') + await Promise.all([rm(app.electron), rm(app.cliJS)]) + + const result = app.run(['list', '--json']) + + assert.equal(result.status, 0, result.stderr.toString()) + assert.equal(result.stdout.toString(), '') + assert.equal(result.stderr.toString(), '') + assert.deepEqual(await app.calls(), ['managed']) + assert.deepEqual(await app.invocation('managed'), { + workspaceSource: 'app', + electronRunAsNode: '', + args: ['list', '--json'], + }) +}) + +test('Go receives exact arguments and streams, and a nonzero exit never retries legacy', async (t) => { + const app = await fixture(t) + await app.executable(join(app.userData, 'cli/zn'), 'managed', 37) + const args = [ + 'write', + 'inbox/café 日本語.md', + '--', + '', + 'spaces & $HOME', + 'first\nsecond', + ] + const input = Buffer.from( + '# café 日本語\n\nTwo trailing spaces. \n\0binary\n', + ) + const stderr = 'A deliberate command error.\n' + + const result = app.run(args, { input, env: { TEST_STDERR: stderr } }) + + assert.equal(result.status, 37) + assert.deepEqual(result.stdout, input) + assert.equal(result.stderr.toString(), stderr) + assert.deepEqual(await app.calls(), ['managed']) + assert.deepEqual((await app.invocation('managed')).args, args) +}) + +test('an explicit workspace source overrides the migrated desktop default', async (t) => { + const app = await fixture(t) + await app.executable(join(app.userData, 'cli/zn'), 'managed') + + const result = app.run(['tui'], { + env: { ZENNOTES_WORKSPACE_SOURCE: 'terminal' }, + }) + + assert.equal(result.status, 0, result.stderr.toString()) + assert.deepEqual(await app.calls(), ['managed']) + assert.equal((await app.invocation('managed')).workspaceSource, 'terminal') +}) + +test('an existing desktop shortcut prefers the persistent managed CLI', async (t) => { + const app = await fixture(t) + await app.executable(join(app.resources, 'terminal/zn'), 'bundled') + await app.executable(join(app.userData, 'cli/zn'), 'managed') + + const result = app.run(['vault', 'info', '--json']) + + assert.equal(result.status, 0, result.stderr.toString()) + assert.deepEqual(await app.calls(), ['managed']) + assert.deepEqual(await app.invocation('managed'), { + workspaceSource: 'app', + electronRunAsNode: '', + args: ['vault', 'info', '--json'], + }) +}) + +test('the legacy override selects Electron even when both Go installations exist', async (t) => { + const app = await fixture(t) + await app.executable(join(app.resources, 'terminal/zn'), 'bundled') + await app.executable(join(app.userData, 'cli/zn'), 'managed') + const input = 'Existing MCP stdin\n' + + const result = app.run(['mcp'], { + input, + env: { ZENNOTES_CLI_ENGINE: 'legacy' }, + }) + + assert.equal(result.status, 0, result.stderr.toString()) + assert.equal(result.stdout.toString(), input) + assert.equal(result.stderr.toString(), '') + assert.deepEqual(await app.calls(), ['legacy']) + assert.deepEqual((await app.invocation('legacy')).args, [app.cliJS, 'mcp']) + assert.equal((await app.invocation('legacy')).electronRunAsNode, '1') +}) + +test('a desktop bundle without a Go artifact retains the existing Electron CLI', async (t) => { + const app = await fixture(t) + await app.executable(app.electron, 'legacy', 19) + const args = ['capture', '--title', 'Piped note'] + const input = 'The existing CLI remains usable. \n' + const stderr = 'Existing CLI error\n' + + const result = app.run(args, { input, env: { TEST_STDERR: stderr } }) + + assert.equal(result.status, 19) + assert.equal(result.stdout.toString(), input) + assert.equal(result.stderr.toString(), stderr) + assert.deepEqual(await app.calls(), ['legacy']) + assert.deepEqual((await app.invocation('legacy')).args, [app.cliJS, ...args]) + assert.equal((await app.invocation('legacy')).electronRunAsNode, '1') +}) + +test('an unactivated Go candidate cannot bypass desktop verification', async (t) => { + const app = await fixture(t) + await app.executable(join(app.resources, 'terminal/zn'), 'unverified', 23) + const result = app.run(['write', 'inbox/Existing.md']) + assert.equal(result.status, 0) + assert.deepEqual(await app.calls(), ['legacy']) +}) diff --git a/tooling/scripts/test-app-core-browser.mjs b/tooling/scripts/test-app-core-browser.mjs new file mode 100644 index 00000000..3d0f8620 --- /dev/null +++ b/tooling/scripts/test-app-core-browser.mjs @@ -0,0 +1,794 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import net from 'node:net' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { resolveServerBinary } from './server-binary.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const manifest = resolve(process.argv[2] || join(root, 'dist/shared-packages/app-core-consumer.json')) +const evidence = JSON.parse(await readFile(manifest, 'utf8')) +const consumer = evidence.consumer +const run = await mkdtemp(join(consumer, 'browser-')) +console.log(`Browser evidence: ${run}`) +const vault = join(run, 'vault') +await mkdir(vault, { recursive: true }) +const require = createRequire(join(consumer, 'package.json')) +const sleep = (ms) => new Promise((done) => setTimeout(done, ms)) +async function until(check, label, timeout = 30000) { + const deadline = Date.now() + timeout + let last + while (Date.now() < deadline) { + try { const value = await check(); if (value) return value } catch (error) { last = error } + await sleep(100) + } + throw new Error(`${label}: ${last?.message || 'timed out'}`) +} +// Public navigation drops calls made while the workspace is still restoring, +// so every scripted navigation after a page load waits for the shell's +// readiness signal, exactly as a host would. +const workspaceReady = () => until(() => client.evaluate('window.packageShell?.getShellSnapshot().workspaceRestored === true'), 'workspace restored') +async function port() { + return new Promise((done, reject) => { + const server = net.createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const number = server.address().port + server.close(() => done(number)) + }) + }) +} +class CDP { + nextId = 0 + pending = new Map() + listeners = new Map() + constructor(socket) { + this.socket = socket + socket.addEventListener('message', ({ data }) => { + const message = JSON.parse(data) + const pending = this.pending.get(message.id) + if (pending) { + this.pending.delete(message.id); clearTimeout(pending.timer) + if (message.error) pending.reject(new Error(message.error.message)) + else pending.done(message.result) + } else if (typeof message.method === 'string' && this.listeners.has(message.method)) { + this.listeners.get(message.method)(message.params) + } + }) + } + on(method, callback) { this.listeners.set(method, callback) } + send(method, params = {}) { + return new Promise((done, reject) => { + const id = ++this.nextId + const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)) }, 30000) + this.pending.set(id, { done, reject, timer }) + this.socket.send(JSON.stringify({ id, method, params })) + }) + } + async evaluate(expression) { + const value = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }) + if (value.exceptionDetails) throw new Error(value.exceptionDetails.exception?.description || value.exceptionDetails.text) + return value.result?.value + } + close() { this.socket.close() } +} +const apiPort = await port(), uiPort = await port(), debugPort = await port() +// The server is the pinned ZenNotes/znserver release (or an explicit binary or checkout). +const binary = await resolveServerBinary() +const children = [] +const logs = {} +function launch(name, command, args, options) { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], ...options }) + logs[name] = '' + child.stdout.on('data', (chunk) => { logs[name] += chunk }) + child.stderr.on('data', (chunk) => { logs[name] += chunk }) + children.push(child) + return child +} +let client +const errors = [], requests = [], failed = [], networkErrors = [] +const requestUrls = new Map() +const pendingAbsenceProbes = new Set() +const absenceProbes = new Map() +const token = 'isolated-package-test-only-token' +try { + launch('server', binary, [], { cwd: run, env: { ...process.env, + ZENNOTES_BIND: `127.0.0.1:${apiPort}`, ZENNOTES_DEFAULT_VAULT_PATH: vault, + ZENNOTES_CONFIG_PATH: join(run, 'server.json'), ZENNOTES_BROWSE_ROOTS: vault, + ZENNOTES_AUTH_TOKEN: token, ZENNOTES_BASE_PATH: '' + } }) + const api = `http://127.0.0.1:${apiPort}` + await until(async () => (await fetch(`${api}/api/healthz`, { signal: AbortSignal.timeout(1000) })).ok, 'API startup') + const path = 'inbox/Package test.md' + const original = '# Package test\n\nRead and edit this note.\n' + const lazyPath = 'inbox/Lazy features.md' + const lazyBody = '# Lazy features\n\n$$ x^2 + y^2 $$\n\n```mermaid\ngraph LR\n A[Packaged] --> B[Working]\n```\n' + const commandPath = 'inbox/Commands.md' + const hostPath = 'inbox/Host hooks.md' + const hostBody = Array.from({length:160}, (_,index) => `Host scroll line ${index + 1}`).join('\n') + const orderedPaths = ['inbox/Order/Note 2.md', 'inbox/Order/Note 10.md', 'inbox/Order/Note 20.md'] + await mkdir(join(vault, 'inbox/Order/People.base/pages'), { recursive: true }) + const databasePath = 'inbox/Browse demo/Customers.base/data.csv' + const schemaPath = 'inbox/Browse demo/Customers.base/schema.json' + const databaseBytes = 'id,Name\nrow-1,Example customer\n' + const schemaBytes = JSON.stringify({version:1,idFieldId:'id',fields:[{id:'id',name:'id',type:'text',hidden:true},{id:'name',name:'Name',type:'text'}],views:[{id:'table',name:'Table',type:'table',filters:[],sorts:[],columnOrder:['name']}],activeViewId:'table'}) + await mkdir(join(vault, 'inbox/Browse demo/Customers.base'), { recursive: true }) + await mkdir(join(vault, 'inbox/Browse demo/Empty'), { recursive: true }) + await writeFile(join(vault, databasePath), databaseBytes) + await writeFile(join(vault, schemaPath), schemaBytes) + const orderFixtures = [...orderedPaths.map(path => [path, `Original ${path}`]), ['inbox/Order/People.base/pages/Hidden.md', 'Database record']] + for (const [notePath, body] of [[path, original], [lazyPath, lazyBody], [commandPath, 'Format me'], [hostPath, hostBody], ['inbox/Browse demo/Read me.md', 'Opened through the public Browse model.'], ...orderFixtures]) { + const response = await fetch(`${api}/api/notes/write`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ path: notePath, body }) }) + assert.equal(response.status, 200) + } + launch('preview', process.execPath, [join(dirname(require.resolve('vite/package.json')), 'bin/vite.js'), 'preview', '--port', String(uiPort), '--strictPort'], { + cwd: consumer, env: { ...process.env, ZEN_CORE_SERVER: api } + }) + const url = `http://127.0.0.1:${uiPort}` + await until(async () => (await fetch(url, { signal: AbortSignal.timeout(1000) })).ok, 'built consumer startup') + const chrome = process.env.ZEN_CHROME_PATH || (process.platform === 'darwin' + ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : 'google-chrome') + launch('chrome', chrome, [`--remote-debugging-port=${debugPort}`, `--user-data-dir=${join(run, 'chrome-profile')}`, + '--headless=new', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--disable-extensions', 'about:blank']) + const page = await until(async () => { + const pages = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`, { signal: AbortSignal.timeout(1000) })).json() + return pages.find((page) => page.type === 'page' && page.webSocketDebuggerUrl) + }, 'Chrome startup') + const socket = new WebSocket(page.webSocketDebuggerUrl) + await new Promise((done, reject) => { socket.addEventListener('open', done, { once: true }); socket.addEventListener('error', reject, { once: true }) }) + client = new CDP(socket) + await client.send('Page.enable'); await client.send('Runtime.enable'); await client.send('Network.enable') + await client.send('Emulation.setFocusEmulationEnabled', { enabled: true }) + await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false }) + client.on('Runtime.exceptionThrown', (event) => errors.push(event.exceptionDetails.exception?.description || event.exceptionDetails.text)) + client.on('Runtime.consoleAPICalled', event => { + const message = event.args.map(arg => arg.value ?? arg.description ?? '').join(' ') + if (event.type === 'error' || /Measure loop restarted|Viewport failed to stabilize/.test(message)) errors.push(message) + }) + client.on('Log.entryAdded', ({ entry }) => { + if (entry.level !== 'error') return + if (entry.source === 'network') networkErrors.push(entry) + else errors.push(entry.text) + }) + client.on('Network.requestWillBeSent', ({ requestId, request }) => { + requestUrls.set(requestId, request.url) + if (request.method === 'GET' && pendingAbsenceProbes.delete(request.url)) { + absenceProbes.set(requestId, {url:request.url}) + } + }) + client.on('Network.responseReceived', ({ requestId, response }) => { + requests.push(response.url) + const probe = absenceProbes.get(requestId) + if (probe) probe.status = response.status + if (response.status >= 400) failed.push({ requestId, url: response.url, status: response.status }) + }) + client.on('Network.loadingFailed', (event) => { + // Navigation intentionally cancels in-flight requests from the old document. + if (!event.canceled) failed.push({ url: requestUrls.get(event.requestId), error: event.errorText }) + }) + await client.send('Log.enable') + await client.send('Page.addScriptToEvaluateOnNewDocument', { source: `localStorage.setItem('zen:prefs:v2', JSON.stringify({vimMode:false,livePreview:false,mathRenderer:'typst'}))` }) + await client.send('Page.navigate', { url }) + await until(() => client.evaluate(`!!document.querySelector('input[placeholder="Enter the server auth token"]')`), 'login') + await client.evaluate(`document.querySelector('input[placeholder="Enter the server auth token"]').focus()`) + await client.send('Input.insertText', { text: token }) + await client.evaluate(`[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Sign In').click()`) + await until(() => client.evaluate(`(() => { [...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Skip setup')?.click(); return !!document.querySelector('[data-sidebar-type="folder"]') })()`), 'workspace') + await workspaceReady() + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(path)})`) + // The first note open fetches the editor, store, and Markdown chunks on a + // cold runner; allow the same window as the lazy renders below. + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Read and edit')`), 'editor', 60000) + assert.equal(await client.evaluate(`document.querySelector('[data-consumer-selection]').textContent`), path) + assert.equal(await client.evaluate(`getComputedStyle(document.querySelector('#root > *')).display`), 'flex', 'Compiled Tailwind styles did not load') + const beforeLazy = requests.filter((url) => /mermaid\.core|typst.*\.wasm|harper.*\.wasm/.test(url)) + assert.deepEqual(beforeLazy, [], 'Heavy features loaded before requested') + await client.evaluate(`document.querySelector('.cm-content').focus()`) + const modifier = process.platform === 'darwin' ? 4 : 2 + async function shortcut(key, code) { + await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers: modifier }) + await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, modifiers: modifier }) + } + await shortcut('a', 'KeyA') + const saved = '# Package test\n\nSaved from the installed editor: café 日本語. \n\n- [ ] Preserve this task\n' + await client.send('Input.insertText', { text: saved }) + await until(async () => (await readFile(join(vault, path), 'utf8')) === saved, 'exact UTF-8 save') + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(lazyPath)})`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(lazyPath)}`), 'public hook updates') + await client.evaluate('window.packageNavigation.goBack()') + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Saved from the installed editor')`), 'public back navigation') + await client.evaluate('window.packageNavigation.goForward()') + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Lazy features')`), 'public forward navigation') + // CodeMirror updates its document before React commits the new toolbar's + // callbacks. Wait for the painted note before interacting with that toolbar. + await client.evaluate('new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))') + await until(() => client.evaluate(`(() => { const button = [...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Preview'); if (!button) return false; button.click(); return true })()`), 'Preview control') + await until(() => client.evaluate(`!!document.querySelector('[aria-label="Note preview"]')`), 'Preview mode') + await until(() => client.evaluate(`document.querySelector('.prose-zen .mermaid svg') && document.querySelector('.prose-zen .zen-typst-math svg')`), 'lazy Mermaid and Typst render', 60000) + const screenshot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'lazy-features.png'), Buffer.from(screenshot.data, 'base64')) + await client.evaluate('window.packageNavigation.goHome()') + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === 'Home'`), 'public Home navigation') + // Native hosts can omit Harper; the default web package must also prove the + // real worker/binary path works when the preference is enabled. + await client.send('Page.addScriptToEvaluateOnNewDocument', { source: `localStorage.setItem('zen:prefs:v2', JSON.stringify({vimMode:false,livePreview:false,harperEnabled:true}))` }) + await client.send('Page.navigate', { url: `${url}/?grammar=1` }) + await until(() => client.evaluate(`location.search === '?grammar=1' && !!window.packageNavigation`), 'reload') + await workspaceReady() + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(path)})`) + await until(() => client.evaluate(`!!document.querySelector('.cm-content')`), 'reloaded editor') + await client.evaluate(`document.querySelector('.cm-content').focus()`) + await shortcut('a', 'KeyA') + await client.send('Input.insertText', { text: '# Grammar\n\nThis is an mispelled sentense.\n' }) + await until(() => client.evaluate(`!!document.querySelector('.cm-harper-lint')`), 'Harper worker diagnostics', 60000) + const grammarShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'grammar.png'), Buffer.from(grammarShot.data, 'base64')) + + // Exercise the public attachment surface using the real host upload path. + // This server owns one immutable test vault, so its importer stays current. + await client.evaluate(`window.attachmentImporter = { + isCurrent: () => true, + importFile: async (notePath, file) => { + const [asset] = await window.zen.importFilesToNote(notePath, [window.zen.getPathForFile(file)]) + if (!asset) throw new Error('Host did not return an imported file') + return asset + }, + importPastedImage: input => window.zen.importPastedImage(input) + }`) + await client.evaluate(`document.querySelector('.cm-content').focus()`) + await shortcut('a', 'KeyA') + const attachmentBody = '# Attachments\n\nKeep this note.\n' + await client.send('Input.insertText', { text: attachmentBody }) + await until(async () => (await readFile(join(vault, path), 'utf8')) === attachmentBody, 'attachment baseline save') + const attached = await client.evaluate(`window.packageEditor.attachFiles( + window.packageEditor.captureEditorInsertion(window.attachmentImporter), + [new File(['public attachment bytes'], 'public-attachment.txt', {type:'text/plain'})])`) + assert.equal(attached.status, 'inserted') + const attachedBody = attachmentBody + attached.assets[0].markdown + await until(async () => (await readFile(join(vault, path), 'utf8')) === attachedBody, 'exact attachment Markdown saved') + assert.equal(await readFile(join(vault, attached.assets[0].path), 'utf8'), 'public attachment bytes') + const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==' + const pasted = await client.evaluate(`window.packageEditor.insertPastedImage( + window.packageEditor.captureEditorInsertion(window.attachmentImporter), + {data:Uint8Array.from(atob(${JSON.stringify(pngBase64)}),c=>c.charCodeAt(0)),mimeType:'image/png',suggestedName:'public-paste.png'})`) + assert.equal(pasted.status, 'inserted') + const pastedBody = attachedBody + '\n\n' + pasted.assets[0].markdown + '\n' + await until(async () => (await readFile(join(vault, path), 'utf8')) === pastedBody, 'exact pasted image Markdown saved') + assert.deepEqual(await readFile(join(vault, pasted.assets[0].path)), Buffer.from(pngBase64, 'base64')) + const beforeRace = await readFile(join(vault, path), 'utf8') + const otherBeforeRace = await readFile(join(vault, lazyPath), 'utf8') + const uploadCount = () => requests.filter(url => url.endsWith('/api/assets/upload')).length + const beforeUploads = uploadCount() + await client.evaluate(`(() => { + window.attachmentSaved = false + const importer = {...window.attachmentImporter, importFile: async (notePath,file) => { + const asset = await window.attachmentImporter.importFile(notePath,file) + window.attachmentSaved = true + await new Promise(done => {window.finishAttachment = done}) + return asset + }} + const target = window.packageEditor.captureEditorInsertion(importer) + if (!target) throw new Error('No attachment target') + window.pendingAttachment = window.packageEditor.attachFiles(target, + [new File(['keep saved asset'],'slow-attachment.txt'),new File(['must not import'],'second-attachment.txt')]) + })()`) + await until(() => client.evaluate('window.attachmentSaved'), 'slow attachment saved') + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(lazyPath)})`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(lazyPath)}`), 'note switch during upload') + await client.evaluate('window.finishAttachment()') + const stale = await client.evaluate('window.pendingAttachment') + assert.equal(stale.status, 'saved-only') + assert.equal(stale.assets.length, 1) + assert.equal(uploadCount() - beforeUploads, 1, 'Import continued after the note changed') + assert.equal(await readFile(join(vault, stale.assets[0].path), 'utf8'), 'keep saved asset') + assert.equal(await readFile(join(vault, path), 'utf8'), beforeRace) + assert.equal(await readFile(join(vault, lazyPath), 'utf8'), otherBeforeRace) + const attachmentShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'attachment-race.png'), Buffer.from(attachmentShot.data, 'base64')) + + // Host buttons use only named public commands. Real mouse events move focus + // onto each button before its handler runs, as an external toolbar can do. + await client.send('Page.navigate', { url: `${url}/?commands=1` }) + await until(() => client.evaluate(`!!document.querySelector('[data-consumer-command]') && window.packageShell?.getShellSnapshot().workspaceRestored`), 'host command toolbar and restored workspace') + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(commandPath)})`) + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent === 'Format me'`), 'command note') + async function clickHostCommand(command) { + const point = await client.evaluate(`(() => { + const button = document.querySelector('[data-consumer-command="${command}"]') + const rect = button.getBoundingClientRect() + return {x:rect.x + rect.width/2, y:rect.y + rect.height/2} + })()`) + await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', clickCount: 1 }) + await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', clickCount: 1 }) + assert.deepEqual(await client.evaluate('window.lastHostCommand'), { command, handled: true }) + } + await client.evaluate(`document.querySelector('.cm-content').focus()`) + await shortcut('a', 'KeyA') + assert.equal(await client.evaluate('window.packageEditor.hasEditorSelection()'), true) + await clickHostCommand('toggle-bold') + await until(async () => (await readFile(join(vault, commandPath), 'utf8')) === '**Format me**', 'public bold save') + assert.equal(await client.evaluate(`document.activeElement.classList.contains('cm-content')`), true) + await clickHostCommand('undo') + await until(async () => (await readFile(join(vault, commandPath), 'utf8')) === 'Format me', 'public undo save') + await clickHostCommand('redo') + await until(async () => (await readFile(join(vault, commandPath), 'utf8')) === '**Format me**', 'public redo save') + await clickHostCommand('open-search') + assert.deepEqual(await client.evaluate(`(() => { + const field = document.querySelector('.cm-search [main-field]') + return {focused:document.activeElement === field, value:field?.value, selection:[field?.selectionStart,field?.selectionEnd]} + })()`), { focused: true, value: 'Format me', selection: [0, 9] }) + const commandsShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'commands.png'), Buffer.from(commandsShot.data, 'base64')) + // A native back handler does not take focus before closing the search panel. + assert.equal(await client.evaluate(`window.packageEditor.runEditorCommand('close-search')`), true) + assert.equal(await client.evaluate(`document.activeElement.classList.contains('cm-content')`), true) + assert.equal(await client.evaluate(`window.packageEditor.runEditorCommand('close-search')`), false) + await shortcut('a', 'KeyA') + await client.send('Input.insertText', { text: ' ' }) + assert.equal(await client.evaluate('window.packageEditor.hasEditorSelection()'), false) + await clickHostCommand('set-task-list') + await until(async () => (await readFile(join(vault, commandPath), 'utf8')) === ' - [ ] ', 'empty-line task marker save') + + await client.send('Page.navigate', { url: `${url}/?host=1` }) + await until(() => client.evaluate('!!window.packageHost'), 'host registration before mount') + await workspaceReady() + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(hostPath)})`) + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Host scroll line 1')`), 'host note') + await client.evaluate(`document.querySelector('.cm-content').focus()`) + assert.deepEqual(await client.evaluate('window.firstEditorTyping'), ['on', 'sentences', 'true', 'true']) + await shortcut(process.platform === 'darwin' ? 'ArrowDown' : 'End', process.platform === 'darwin' ? 'ArrowDown' : 'End') + await until(() => client.evaluate(`window.getSelection()?.focusNode?.parentElement?.closest('.cm-line')?.textContent === 'Host scroll line 160'`), 'caret at note end') + const caretIsClear = () => client.evaluate(`(() => { + const cursor = document.querySelector('.cm-cursor')?.getBoundingClientRect() + const scroller = document.querySelector('.cm-scroller').getBoundingClientRect() + const toolbar = document.getElementById('host-selection-toolbar') ?? document.getElementById('host-keyboard-toolbar') + return !!cursor && cursor.height > 0 && cursor.top >= scroller.top && cursor.bottom <= Math.min(scroller.bottom, toolbar.getBoundingClientRect().top) - 4 + })()`) + await client.evaluate(`document.querySelector('.cm-scroller').scrollTop = 0; window.packageHost.refresh(); window.packageEditor.revealEditorCaret()`) + await until(caretIsClear, 'caret above host keyboard toolbar') + await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 700, deviceScaleFactor: 1, mobile: false }) + await client.evaluate(`document.getElementById('host-keyboard-toolbar').style.height = '96px'; window.packageHost.refresh(); window.packageEditor.revealEditorCaret()`) + await until(caretIsClear, 'caret above resized keyboard toolbar') + await client.evaluate(`(() => { + const selection = document.createElement('div') + selection.id = 'host-selection-toolbar' + selection.textContent = 'Host selection toolbar' + selection.style.cssText = 'position:fixed;bottom:96px;left:0;right:0;height:140px;background:#40585b;color:white;z-index:10000;pointer-events:none' + document.body.append(selection) + window.packageHost.refresh() + window.packageEditor.revealEditorCaret() + })()`) + await until(() => client.evaluate(`document.querySelector('.cm-scroller').getBoundingClientRect().bottom <= document.getElementById('host-selection-toolbar').getBoundingClientRect().top`), 'physical selection clearance') + await until(caretIsClear, 'caret above selection toolbar') + const hostShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'host-insets.png'), Buffer.from(hostShot.data, 'base64')) + await client.evaluate(`window.packageHost.dispose(); document.getElementById('host-selection-toolbar').remove(); document.getElementById('host-keyboard-toolbar').remove()`) + assert.deepEqual(await client.evaluate(`['autocorrect','autocapitalize','spellcheck','writingsuggestions'].map(name => document.querySelector('.cm-content').getAttribute(name))`), ['off', 'off', 'false', 'false']) + assert.equal(await client.evaluate(`document.querySelector('.cm-editor').style.getPropertyValue('--zen-editor-host-bottom-inset')`), '') + assert.equal(await readFile(join(vault, hostPath), 'utf8'), hostBody, 'Host configuration changed note bytes') + + await client.send('Page.addScriptToEvaluateOnNewDocument', { source: `localStorage.setItem('zen:prefs:v2', JSON.stringify({vimMode:false,livePreview:false,noteSortOrder:'name-asc'}))` }) + await client.send('Page.navigate', { url: `${url}/?shell=1` }) + await until(() => client.evaluate(`!!document.querySelector('[data-consumer-adjacent]')`), 'host note navigation') + await workspaceReady() + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(orderedPaths[0])})`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-title]')?.textContent === 'Note 2'`), 'shell React snapshot') + const shellProof = await client.evaluate(`(() => { + const api = window.packageShell, snapshot = api.getShellSnapshot() + let immutable = false + try { Object.assign(snapshot.notes[0], {title:'Changed'}) } catch { immutable = true } + window.shellTransitions = [] + window.disposeShell = api.subscribeShell((next, previous) => window.shellTransitions.push([previous.selectedPath, next.selectedPath])) + return { + immutable, stable: snapshot === api.getShellSnapshot(), + selected: snapshot.selectedNote.path, + bodyExposed: snapshot.notes.some(note => 'body' in note), + order: api.getBrowseNotes(snapshot, 'Order').map(note => note.path), + pinned: api.getBrowseNotes(snapshot, 'Order', [${JSON.stringify(orderedPaths[2])}]).map(note => note.path), + hidden: api.getBrowseNotes(snapshot, 'Order/People.base/pages').length, + previous: api.getAdjacentNotePath(snapshot, snapshot.selectedPath, 'previous') + } + })()`) + assert.deepEqual(shellProof, { immutable: true, stable: true, selected: orderedPaths[0], bodyExposed: false, order: orderedPaths, pinned: [orderedPaths[2], ...orderedPaths.slice(0,2)], hidden: 0, previous: null }) + async function clickAdjacent(direction) { + const point = await client.evaluate(`(() => { + const rect = document.querySelector('[data-consumer-adjacent="${direction}"]').getBoundingClientRect() + return {x:rect.x + rect.width/2, y:rect.y + rect.height/2} + })()`) + await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', clickCount: 1 }) + await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', clickCount: 1 }) + } + await clickAdjacent('next') + await until(() => client.evaluate(`document.querySelector('[data-consumer-title]').textContent === 'Note 10'`), 'next Browse sibling') + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Original inbox/Order/Note 10.md')`), 'adjacent editor ready') + await client.evaluate(`document.querySelector('.cm-content').focus()`) + await shortcut('a', 'KeyA') + await client.send('Input.insertText', { text: 'Edited via public sibling navigation: café.' }) + await clickAdjacent('next') + await until(() => client.evaluate(`document.querySelector('[data-consumer-title]').textContent === 'Note 20'`), 'second Browse sibling') + await until(async () => (await readFile(join(vault, orderedPaths[1]), 'utf8')) === 'Edited via public sibling navigation: café.', 'sibling navigation saves edited note') + await clickAdjacent('next') + assert.equal(await client.evaluate('window.packageShell.getShellSnapshot().selectedPath'), orderedPaths[2], 'Adjacent navigation wrapped') + await clickAdjacent('previous') + await until(() => client.evaluate(`document.querySelector('[data-consumer-title]').textContent === 'Note 10'`), 'previous Browse sibling') + const shellShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'shell.png'), Buffer.from(shellShot.data, 'base64')) + assert.ok((await client.evaluate('window.shellTransitions')).some(([previous, next]) => previous === orderedPaths[0] && next === orderedPaths[1])) + await client.evaluate('window.disposeShell(); window.shellTransitions = []; window.packageNavigation.goHome()') + await until(() => client.evaluate(`document.querySelector('[data-consumer-title]').textContent === 'No note'`), 'shell hook Home') + assert.deepEqual(await client.evaluate('window.shellTransitions'), [], 'Disposed shell subscriber still notified') + for (const [notePath, body] of orderFixtures) { + if (notePath !== orderedPaths[1]) assert.equal(await readFile(join(vault, notePath), 'utf8'), body, 'Sibling navigation changed another note') + } + + await client.send('Page.navigate', { url: `${url}/?browse=1` }) + await until(() => client.evaluate(`!!document.querySelector('[data-browse-folder="Browse demo"]')`), 'public Browse root') + async function clickBrowse(selector) { + const point = await client.evaluate(`(() => { + const rect = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect() + return {x:rect.x + rect.width/2, y:rect.y + rect.height/2} + })()`) + await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', clickCount: 1 }) + await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', clickCount: 1 }) + } + await clickBrowse('[data-browse-folder="Browse demo"]') + await until(() => client.evaluate(`!!document.querySelector('[data-browse-database="Browse demo/Customers.base"]')`), 'public Browse database row') + assert.deepEqual(await client.evaluate(`(() => { + const snapshot = window.packageBrowse.getBrowseSnapshot() + const rows = window.packageBrowse.getBrowseDirectory(snapshot, 'Browse demo') + return {folders:rows.folders.map(row => row.title), databases:rows.databases.map(row => row.title), notes:rows.notes.map(row => row.title), frozen:Object.isFrozen(snapshot.folders) && Object.isFrozen(rows.databases[0])} + })()`), { folders: ['Empty'], databases: ['Customers'], notes: ['Read me'], frozen: true }) + await clickBrowse('[data-browse-database="Browse demo/Customers.base"]') + await until(() => client.evaluate(`!!document.querySelector('[role="grid"]') && document.body.innerText.includes('Example customer')`), 'database opens from public Browse') + assert.equal(await readFile(join(vault, databasePath), 'utf8'), databaseBytes, 'Opening Browse database changed CSV bytes') + assert.equal(await readFile(join(vault, schemaPath), 'utf8'), schemaBytes, 'Opening Browse database changed schema bytes') + const browseShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'browse.png'), Buffer.from(browseShot.data, 'base64')) + await clickBrowse('[data-browse-note="inbox/Browse demo/Read me.md"]') + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent === 'Opened through the public Browse model.'`), 'note opens from public Browse') + await client.evaluate(`window.browseChanges = []; window.disposeBrowse = window.packageBrowse.subscribeBrowse(next => window.browseChanges.push(next.folders.map(row => row.directory)))`) + await client.evaluate(`window.zen.createFolder('inbox', 'Browse demo/Later')`) + await until(() => client.evaluate(`!!document.querySelector('[data-browse-folder="Browse demo/Later"]')`), 'Browse hook updates after folder creation') + assert.ok((await client.evaluate('window.browseChanges')).length > 0, 'Browse subscriber missed folder update') + await client.evaluate(`window.disposeBrowse(); window.browseChanges = []; window.zen.createFolder('inbox', 'Browse demo/After disposal')`) + await until(() => client.evaluate(`!!document.querySelector('[data-browse-folder="Browse demo/After disposal"]')`), 'Browse hook stays live after other subscriber disposal') + assert.deepEqual(await client.evaluate('window.browseChanges'), [], 'Disposed Browse subscriber still notified') + async function dialogButton(label) { + const point = await client.evaluate(`(() => { + const button = [...document.querySelectorAll('[role="dialog"] button')].find(button => button.textContent.trim() === ${JSON.stringify(label)}) + const rect = button.getBoundingClientRect() + return {x:rect.x + rect.width/2, y:rect.y + rect.height/2} + })()`) + await client.send('Input.dispatchMouseEvent', {type:'mousePressed', ...point, button:'left', clickCount:1}) + await client.send('Input.dispatchMouseEvent', {type:'mouseReleased', ...point, button:'left', clickCount:1}) + } + async function answerFolderPrompt(name, label) { + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"] input')`), 'folder prompt') + await clickBrowse('[role="dialog"] input') + await until(() => client.evaluate(`document.activeElement === document.querySelector('[role="dialog"] input')`), 'prompt input focused') + await client.send('Input.dispatchKeyEvent', {type:'keyDown', key:'a', code:'KeyA', modifiers:modifier, commands:['selectAll']}) + await client.send('Input.dispatchKeyEvent', {type:'keyUp', key:'a', code:'KeyA', modifiers:modifier}) + await client.send('Input.insertText', { text: name }) + assert.equal(await client.evaluate(`document.querySelector('[role="dialog"] input').value`), name, 'Prompt text replacement') + await dialogButton(label) + await until(() => client.evaluate(`window.lastBrowseAction === 'completed'`), 'folder action completes') + } + await clickBrowse('[data-browse-create]') + await answerFolderPrompt('Action folder', 'Create') + await until(() => client.evaluate(`!!document.querySelector('[data-browse-folder="Browse demo/Action folder"]')`), 'created folder appears') + const actionNote = 'inbox/Browse demo/Action folder/Keep.md' + const actionBody = 'Folder action bytes: café 日本語. \n' + const writtenAction = await fetch(`${api}/api/notes/write`, {method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify({path:actionNote, body:actionBody})}) + assert.equal(writtenAction.status, 200) + const commentBody = { path: actionNote, comments: [{id:'folder-comment',body:'Keep this thread',createdAt:1,updatedAt:1}] } + const commentWrite = await fetch(`${api}/api/comments/write`, {method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify(commentBody)}) + assert.equal(commentWrite.status, 200) + await clickBrowse('[data-browse-database="Browse demo/Customers.base"]') + await until(() => client.evaluate(`!!document.querySelector('[role="grid"]')`), 'database active before parent rename') + await clickBrowse('[data-browse-parent]') + await clickBrowse('[data-browse-rename="Browse demo"]') + await answerFolderPrompt('Browse renamed', 'Rename') + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent.includes('Browse%20renamed')`), 'active database tab follows parent rename') + assert.equal(await readFile(join(vault, 'inbox/Browse renamed/Customers.base/data.csv'), 'utf8'), databaseBytes) + assert.equal(await readFile(join(vault, 'inbox/Browse renamed/Customers.base/schema.json'), 'utf8'), schemaBytes) + const renamedAction = 'inbox/Browse renamed/Action folder/Keep.md' + assert.equal(await readFile(join(vault, renamedAction), 'utf8'), actionBody) + const commentsAfterRename = await fetch(`${api}/api/comments/read?path=${encodeURIComponent(renamedAction)}`, {headers:{Authorization:`Bearer ${token}`}}).then(response => response.json()) + assert.ok(JSON.stringify(commentsAfterRename).includes('Keep this thread'), 'Folder rename lost comments') + await clickBrowse('[data-browse-folder="Browse renamed"]') + await clickBrowse('[data-browse-delete="Browse renamed/Action folder"]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`), 'folder deletion confirmation') + await dialogButton('Cancel') + await until(() => client.evaluate(`window.lastBrowseAction === 'cancelled'`), 'cancelled folder deletion') + assert.equal(await readFile(join(vault, renamedAction), 'utf8'), actionBody) + await clickBrowse('[data-browse-delete="Browse renamed/Customers.base"]') + await until(() => client.evaluate(`document.body.innerText.includes('All records will be permanently deleted')`), 'database deletion confirmation') + await dialogButton('Delete') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !document.querySelector('[data-browse-database="Browse renamed/Customers.base"]')`), 'database deletion') + assert.equal(await client.evaluate(`window.packageShell.getShellSnapshot().selectedPath?.includes('Customers.base') ?? false`), false, 'Deleted database tab remained active') + await assert.rejects(readFile(join(vault, 'inbox/Browse renamed/Customers.base/data.csv')), {code:'ENOENT'}) + await clickBrowse('[data-browse-delete="Browse renamed/Action folder"]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`), 'confirmed folder deletion') + await dialogButton('Delete') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !document.querySelector('[data-browse-folder="Browse renamed/Action folder"]')`), 'folder removed') + await assert.rejects(readFile(join(vault, renamedAction)), {code:'ENOENT'}) + assert.equal(await readFile(join(vault, 'inbox/Browse renamed/Read me.md'), 'utf8'), 'Opened through the public Browse model.') + const actionShot = await client.send('Page.captureScreenshot', {format:'png'}) + await writeFile(join(run, 'browse-actions.png'), Buffer.from(actionShot.data, 'base64')) + + + // Collision checks deliberately read an absent CSV. Admit only the first GET + // for each exact target, after proving absence on disk; all other failures stay + // fatal. A folder listing cannot replace this check because it hides .base internals. + async function expectAbsentCsv(directory) { + await assert.rejects(readFile(join(vault, directory, 'data.csv')), {code:'ENOENT'}) + pendingAbsenceProbes.add(`${url}/api/notes/read?path=${encodeURIComponent(directory + '/data.csv')}`) + } + const createdDir = 'inbox/Browse renamed/Untitled Database.base' + await expectAbsentCsv(createdDir) + await clickBrowse('[data-browse-create-database]') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !!document.querySelector('[data-browse-database="Browse renamed/Untitled Database.base"]')`), 'created database in Browse') + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent.includes('Untitled%20Database.base') && !!document.querySelector('[role="grid"]')`), 'new database opens') + const createdCsv = await readFile(join(vault, createdDir, 'data.csv'), 'utf8') + const createdSchema = await readFile(join(vault, createdDir, 'schema.json'), 'utf8') + const recordPath = `${createdDir}/Record.md` + const recordBody = '# Record\n\nPreserve café 日本語. \n' + const recordWrite = await fetch(`${api}/api/notes/write`, {method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify({path:recordPath, body:recordBody})}) + assert.equal(recordWrite.status, 200) + const recordComment = await fetch(`${api}/api/comments/write`, {method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify({path:recordPath, comments:[{id:'record-comment',body:'Record discussion',createdAt:1,updatedAt:1}]})}) + assert.equal(recordComment.status, 200) + const projectsDir = 'inbox/Browse renamed/Projects.base' + await expectAbsentCsv(projectsDir) + await clickBrowse('[data-browse-rename-database="Browse renamed/Untitled Database.base"]') + await answerFolderPrompt('Projects', 'Rename') + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent.includes('Projects.base') && !!document.querySelector('[data-browse-database="Browse renamed/Projects.base"]')`), 'renamed database remains selected') + assert.equal(await readFile(join(vault, projectsDir, 'data.csv'), 'utf8'), createdCsv) + assert.equal(await readFile(join(vault, projectsDir, 'schema.json'), 'utf8'), createdSchema) + assert.equal(await readFile(join(vault, projectsDir, 'Record.md'), 'utf8'), recordBody) + const recordComments = await fetch(`${api}/api/comments/read?path=${encodeURIComponent(projectsDir + '/Record.md')}`, {headers:{Authorization:`Bearer ${token}`}}).then(response => response.json()) + assert.ok(JSON.stringify(recordComments).includes('Record discussion')) + await assert.rejects(readFile(join(vault, createdDir, 'data.csv')), {code:'ENOENT'}) + const databaseShot = await client.send('Page.captureScreenshot', {format:'png'}) + await writeFile(join(run, 'database-actions.png'), Buffer.from(databaseShot.data, 'base64')) + + // Move an actively edited note through the external host's public action. + const movingNote = 'inbox/Browse renamed/Read me.md' + await clickBrowse(`[data-browse-note="${movingNote}"]`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(movingNote)} && !!document.querySelector('.cm-content')`), 'note before move') + await client.evaluate(`document.querySelector('.cm-content').focus()`) + await shortcut('a','KeyA') + const movedBody = '# Read me\n\nMove this edited note: café 日本語. \n' + await client.send('Input.insertText', {text:movedBody}) + const movingComment = await fetch(`${api}/api/comments/write`, {method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`},body:JSON.stringify({path:movingNote,comments:[{id:'moving-note-comment',body:'Move my discussion',createdAt:1,updatedAt:1}]})}) + assert.equal(movingComment.status,200) + await clickBrowse(`[data-note-move="${movingNote}"]`) + await answerFolderPrompt('inbox/Moved notes','Move') + const movedNote = 'inbox/Moved notes/Read me.md' + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(movedNote)}`), 'public moved note remains selected') + assert.equal(await readFile(join(vault,movedNote),'utf8'),movedBody) + await assert.rejects(readFile(join(vault,movingNote)),{code:'ENOENT'}) + const movedComments = await fetch(`${api}/api/comments/read?path=${encodeURIComponent(movedNote)}`,{headers:{Authorization:`Bearer ${token}`}}).then(response=>response.json()) + assert.ok(JSON.stringify(movedComments).includes('Move my discussion')) + const moveShot = await client.send('Page.captureScreenshot',{format:'png'}) + await writeFile(join(run,'note-move.png'),Buffer.from(moveShot.data,'base64')) + + // Keep an inbound note active so the rename must update its cached editor, + // then save another edit to prove it cannot restore the old link target. + const linkedNote = 'inbox/Moved notes/Links.md' + const linkedBody = 'See [[Read me#Heading|alias]] and `[[Read me]]`. \n' + const linkedWrite = await fetch(`${api}/api/notes/write`, {method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`},body:JSON.stringify({path:linkedNote,body:linkedBody})}) + assert.equal(linkedWrite.status,200) + await clickBrowse('[data-browse-parent]') + await until(() => client.evaluate(`!!document.querySelector('[data-browse-folder="Moved notes"]')`), 'moved folder in Browse') + await clickBrowse('[data-browse-folder="Moved notes"]') + await until(() => client.evaluate(`!!document.querySelector('[data-browse-note="${linkedNote}"]')`), 'inbound note in Browse') + await clickBrowse(`[data-browse-note="${linkedNote}"]`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(linkedNote)} && document.querySelector('.cm-content')?.textContent.includes('Read me')`), 'inbound editor before rename') + await clickBrowse(`[data-note-rename="${movedNote}"]`) + await answerFolderPrompt('Renamed guide','Rename') + const renamedNote = 'inbox/Moved notes/Renamed guide.md' + const rewrittenBody = linkedBody.replace('[[Read me#', '[[Renamed guide#') + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Renamed guide')`), 'cached inbound editor updated') + assert.equal(await readFile(join(vault,linkedNote),'utf8'),rewrittenBody) + assert.equal(await readFile(join(vault,renamedNote),'utf8'),movedBody.replace('# Read me','# Renamed guide')) + await assert.rejects(readFile(join(vault,movedNote)),{code:'ENOENT'}) + const renamedComments = await fetch(`${api}/api/comments/read?path=${encodeURIComponent(renamedNote)}`,{headers:{Authorization:`Bearer ${token}`}}).then(response=>response.json()) + assert.ok(JSON.stringify(renamedComments).includes('Move my discussion')) + await clickBrowse('.cm-content > .cm-line:last-child') + await until(() => client.evaluate(`document.activeElement === document.querySelector('.cm-content') && document.querySelector('.cm-content').contains(getSelection()?.anchorNode)`), 'inbound editor caret after rename') + await shortcut('End','End') + await client.send('Input.insertText',{text:'After rename: café 日本語.'}) + assert.ok(await client.evaluate(`document.querySelector('.cm-content').textContent.includes('After rename: café 日本語.')`), 'Follow-up keystrokes did not reach the inbound editor') + await until(async () => (await readFile(join(vault, linkedNote),'utf8')).includes('After rename: café 日本語.'), 'follow-up edit saved') + await clickBrowse(`[data-browse-note="${renamedNote}"]`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(renamedNote)} && document.querySelector('.cm-content')?.textContent.includes('Renamed guide')`), 'renamed note opens') + const linkedAfterEdit = await readFile(join(vault,linkedNote),'utf8') + assert.ok(linkedAfterEdit.includes('[[Renamed guide#Heading|alias]]'), 'Saving the cached editor restored the old target') + assert.ok(linkedAfterEdit.includes('`[[Read me]]`'), 'Rename changed an inline-code link') + assert.ok(linkedAfterEdit.includes('After rename: café 日本語.'), 'Edit after rename was lost') + const renameShot = await client.send('Page.captureScreenshot',{format:'png'}) + await writeFile(join(run,'note-rename.png'),Buffer.from(renameShot.data,'base64')) + + // Exercise lifecycle through host-owned controls and real Go storage. + const lifecycleBody = movedBody.replace('# Read me','# Renamed guide') + await clickBrowse(`[data-note-archive="${renamedNote}"]`) + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !!document.querySelector('[data-note-restore="archive/Moved notes/Renamed guide.md"]')`), 'archived note in public shell') + assert.equal(await readFile(join(vault,'archive/Moved notes/Renamed guide.md'),'utf8'),lifecycleBody) + assert.notEqual(await client.evaluate(`document.querySelector('[data-consumer-selection]').textContent`),'archive/Moved notes/Renamed guide.md','Archive closes the clean editor') + await assert.rejects(readFile(join(vault,renamedNote)),{code:'ENOENT'}) + await clickBrowse('[data-note-restore="archive/Moved notes/Renamed guide.md"]') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !document.querySelector('[data-note-restore="archive/Moved notes/Renamed guide.md"]')`),'restore archive') + const restoredNote='inbox/Moved notes/Renamed guide.md' + assert.equal(await readFile(join(vault,restoredNote),'utf8'),lifecycleBody) + await until(() => client.evaluate(`!!document.querySelector('[data-note-trash="${restoredNote}"]')`),'restored note in Browse') + await clickBrowse(`[data-browse-note="${restoredNote}"]`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(restoredNote)}`),'restored note opens') + await clickBrowse(`[data-note-trash="${restoredNote}"]`) + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'trash confirmation') + await dialogButton('Cancel') + await until(() => client.evaluate(`window.lastBrowseAction === 'cancelled'`),'cancel trash') + assert.equal(await readFile(join(vault,restoredNote),'utf8'),lifecycleBody) + await clickBrowse(`[data-note-trash="${restoredNote}"]`) + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'confirmed trash') + await dialogButton('Move to Trash') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !!document.querySelector('[data-note-restore="trash/Moved notes/Renamed guide.md"]')`),'trashed note in public shell') + assert.equal(await readFile(join(vault,'trash/Moved notes/Renamed guide.md'),'utf8'),lifecycleBody) + const trashedComments=await fetch(`${api}/api/comments/read?path=${encodeURIComponent('trash/Moved notes/Renamed guide.md')}`,{headers:{Authorization:`Bearer ${token}`}}).then(response=>response.json()) + assert.ok(JSON.stringify(trashedComments).includes('Move my discussion'),'Trash retained comments') + await clickBrowse('[data-note-restore="trash/Moved notes/Renamed guide.md"]') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !!document.querySelector('[data-note-trash="${restoredNote}"]')`),'restore trash') + assert.equal(await readFile(join(vault,restoredNote),'utf8'),lifecycleBody) + await clickBrowse(`[data-note-trash="${restoredNote}"]`) + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'trash before permanent deletion') + await dialogButton('Move to Trash') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !!document.querySelector('[data-note-delete="trash/Moved notes/Renamed guide.md"]')`),'note ready for permanent deletion') + await clickBrowse('[data-note-delete="trash/Moved notes/Renamed guide.md"]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'permanent deletion confirmation') + await dialogButton('Cancel') + await until(() => client.evaluate(`window.lastBrowseAction === 'cancelled'`),'cancel permanent deletion') + assert.equal(await readFile(join(vault,'trash/Moved notes/Renamed guide.md'),'utf8'),lifecycleBody) + await clickBrowse('[data-note-delete="trash/Moved notes/Renamed guide.md"]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'confirm permanent deletion') + await dialogButton('Delete permanently') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !document.querySelector('[data-note-delete="trash/Moved notes/Renamed guide.md"]')`),'permanently deleted note leaves public shell') + await assert.rejects(readFile(join(vault,'trash/Moved notes/Renamed guide.md')),{code:'ENOENT'}) + const deletedComments=await fetch(`${api}/api/comments/read?path=${encodeURIComponent('trash/Moved notes/Renamed guide.md')}`,{headers:{Authorization:`Bearer ${token}`}}).then(response=>response.json()) + assert.deepEqual(deletedComments,[]) + const lifecycleShot=await client.send('Page.captureScreenshot',{format:'png'}) + await writeFile(join(run,'note-lifecycle.png'),Buffer.from(lifecycleShot.data,'base64')) + + // The same public batch API backs mobile and Sidebar selections. + const batchPaths = ['inbox/Batch boundary/One.md', 'inbox/Batch boundary/Two.md'] + for (const [index, path] of batchPaths.entries()) { + const response = await fetch(`${api}/api/notes/write`, { method:'POST', headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`}, body:JSON.stringify({path,body:`# Batch ${index}\n\nExact café 日本語. \n`}) }) + assert.equal(response.status,200) + } + await until(() => client.evaluate(`window.packageShell.getShellSnapshot().notes.filter(note => note.path.startsWith('inbox/Batch boundary/')).length === 2`), 'batch notes indexed') + await clickBrowse('[data-batch-trash]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'batch confirmation') + await dialogButton('Cancel') + await until(() => client.evaluate(`window.lastBrowseAction === 'cancelled'`),'batch cancelled') + for (const path of batchPaths) assert.ok(await readFile(join(vault,path),'utf8')) + await clickBrowse('[data-batch-trash]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'batch confirmation again') + await dialogButton('Move to Trash') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed'`),'batch completed') + for (const [index,path] of batchPaths.entries()) { + await assert.rejects(readFile(join(vault,path)),{code:'ENOENT'}) + assert.equal(await readFile(join(vault,path.replace('inbox/','trash/')),'utf8'),`# Batch ${index}\n\nExact café 日本語. \n`) + } + await clickBrowse('[data-empty-trash]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'empty Trash confirmation') + await dialogButton('Cancel') + await until(() => client.evaluate(`window.lastBrowseAction === 'cancelled'`),'empty Trash cancelled') + for (const path of batchPaths) assert.ok(await readFile(join(vault,path.replace('inbox/','trash/')),'utf8')) + await clickBrowse('[data-empty-trash]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'empty Trash confirmation again') + await dialogButton('Empty trash') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed'`),'empty Trash completed') + for (const path of batchPaths) await assert.rejects(readFile(join(vault,path.replace('inbox/','trash/'))),{code:'ENOENT'}) + + // Delete linked records through the actual grid context menu. + const rowsSchema = { + version: 1, idFieldId:'f_id', activeViewId:'table', + fields:[{id:'f_id',name:'ID',type:'text',hidden:true},{id:'f_name',name:'Name',type:'text'},{id:'f_status',name:'Status',type:'text'}], + views:[{id:'table',name:'Table',type:'table',filters:[],sorts:[],columnOrder:['f_name','f_status'],hiddenFieldIds:['f_id']}], + pages:{record1:`${projectsDir}/Record.md`,record2:`${projectsDir}/Two.md`} + } + const rowData = [{id:'record1',cells:{f_id:'record1',f_name:'Record',f_status:'Ready'}},{id:'record2',cells:{f_id:'record2',f_name:'Two',f_status:'Open'}}] + const secondPage = '# Two\n\nSecond exact page. \n' + await client.evaluate(`window.zen.writeNote(${JSON.stringify(projectsDir + '/Two.md')}, ${JSON.stringify(secondPage)})`) + await client.evaluate(`window.zen.writeDatabaseSchema(${JSON.stringify(projectsDir + '/data.csv')}, ${JSON.stringify(rowsSchema)}, ${JSON.stringify(rowData)})`) + await client.send('Page.navigate', { url: `${url}/?browse=1&rows=1` }) + await until(() => client.evaluate(`location.search.includes('rows=1') && window.packageBrowse?.getBrowseSnapshot().databases.some(row => row.title === 'Projects')`), 'seeded database indexed after reload') + await workspaceReady() + await client.evaluate(`window.packageNavigation.openNote(window.packageBrowse.getBrowseSnapshot().databases.find(row => row.title === 'Projects').path)`) + await until(() => client.evaluate(`document.querySelector('[role="grid"]')?.textContent.includes('Ready')`),'linked rows loaded') + async function deleteFirstRecord(choice) { + const point = await client.evaluate(`(() => { const rect=document.querySelector('[role="grid"] tbody tr td:nth-child(2)').getBoundingClientRect(); return {x:rect.x+rect.width/2,y:rect.y+rect.height/2} })()`) + await client.send('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'right',clickCount:1}) + await client.send('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'right',clickCount:1}) + await until(() => client.evaluate(`!![...document.querySelectorAll('[role="menu"] button')].find(button => button.textContent.trim() === 'Delete row')`),'row context menu') + const menuPoint = await client.evaluate(`(() => { const rect=[...document.querySelectorAll('[role="menu"] button')].find(button => button.textContent.trim() === 'Delete row').getBoundingClientRect(); return {x:rect.x+rect.width/2,y:rect.y+rect.height/2} })()`) + await client.send('Input.dispatchMouseEvent',{type:'mousePressed',...menuPoint,button:'left',clickCount:1}) + await client.send('Input.dispatchMouseEvent',{type:'mouseReleased',...menuPoint,button:'left',clickCount:1}) + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'linked page confirmation') + await dialogButton(choice) + } + await deleteFirstRecord('Keep note') + await until(async () => !(await readFile(join(vault,projectsDir,'data.csv'),'utf8')).includes('record1'),'first row detached') + assert.equal(await readFile(join(vault,projectsDir,'Record.md'),'utf8'),`---\nStatus: Ready\n---\n${recordBody}`) + assert.equal(JSON.parse(await readFile(join(vault,projectsDir,'schema.json'),'utf8')).pages.record1,undefined) + await deleteFirstRecord('Delete row + note') + await until(async () => !(await readFile(join(vault,projectsDir,'data.csv'),'utf8')).includes('record2'),'second row deleted') + await until(async () => { try { await readFile(join(vault,projectsDir,'Two.md')); return false } catch(error) { return error.code === 'ENOENT' } },'second page trashed') + assert.equal(await readFile(join(vault,projectsDir.replace('inbox/','trash/'),'Two.md'),'utf8'),`---\nStatus: Open\n---\n${secondPage}`) + const rowsShot = await client.send('Page.captureScreenshot',{format:'png'}) + await writeFile(join(run,'database-row-lifecycle.png'),Buffer.from(rowsShot.data,'base64')) + + assert.equal(pendingAbsenceProbes.size, 0, 'Expected collision probes were not sent') + assert.equal(absenceProbes.size, 2, 'Expected exactly two collision probes') + for (const probe of absenceProbes.values()) assert.equal(probe.status, 404, `Collision probe: ${probe.url}`) + const expectedAbsence = (requestId) => absenceProbes.get(requestId)?.status === 404 + for (const entry of networkErrors) { + if (!expectedAbsence(entry.networkRequestId) || entry.text !== 'Failed to load resource: the server responded with a status of 404 (Not Found)') errors.push(entry.text) + } + const unexpectedFailures = failed.filter(entry => entry.status !== 404 || !expectedAbsence(entry.requestId)) + assert.deepEqual(errors, [], 'Unexpected browser errors') + assert.deepEqual(unexpectedFailures, [], 'Unexpected failed application requests') + const result = { candidate: evidence.candidate, passed: ['installed editor loads', 'compiled styles', 'host React hook', 'exact UTF-8 save', 'public navigation', 'deferred heavy features', 'Mermaid SVG', 'Typst WASM and bundled fonts', 'Harper worker diagnostics', 'reload', 'public file attachment with exact saved bytes', 'public image paste with exact saved bytes', 'note switch stops insertion and remaining uploads', 'saved asset retained after note switch', 'host toolbar formatting with exact saved bytes', 'public undo and redo', 'Find field focus and native-back close', 'public selection query', 'empty-line list creation', 'no browser errors'], attachments: { attached, pasted, stale }, requests, errors, failed } + result.passed.push('native typing before first focus', 'caret above keyboard after viewport resize', 'physical selection toolbar clearance', 'host disposal restores defaults without note edits') + result.passed.push('immutable shell metadata and React hook', 'natural and pinned Browse order', 'hidden database records excluded', 'adjacent navigation saves exact bytes and stops at boundaries', 'shell subscription disposal') + result.passed.push('Browse folder/database React model', 'database and note navigation from public Browse preserves bytes', 'Browse live folder refresh and subscription disposal', 'public folder creation prompt', 'parent folder rename preserves active database and exact bytes', 'folder comments follow rename', 'confirmed database deletion closes its tab', 'folder deletion cancellation and confirmation') + result.passed.push('public database creation opens the canonical tab', 'public database rename preserves CSV, schema, records, and comments') + result.passed.push('public note move preserves active editor, exact saved bytes, and comments') + result.passed.push('public note rename preserves heading, exact bytes, and comments', 'cached inbound editor rewrites links and retains later edits') + result.passed.push('public archive and restore preserve exact bytes and canonical paths', 'public trash cancellation and confirmation preserve bytes and comments', 'public restore from trash', 'permanent deletion cancellation and confirmation remove content and comments') + result.passed.push('grid row deletion preserves standalone page properties and body', 'grid row deletion moves linked page after database save') + result.passed.push('public batch trash cancellation and exact bytes', 'public Empty Trash cancellation and deletion') + result.passed[result.passed.indexOf('no browser errors')] = 'no unexpected browser errors' + result.expectedAbsenceProbes = [...absenceProbes.values()] + result.networkErrors = networkErrors + result.failed = unexpectedFailures + await writeFile(join(run, 'result.json'), JSON.stringify(result, null, 2) + '\n') + console.log(`PASS: ${result.passed.join(', ')}\nEvidence: ${run}`) +} catch (error) { + // CI keeps only the console, so say what the page and the helpers saw + // before the evidence directory is uploaded or lost. + console.error(`Browser check failed: ${error.message}`) + console.error(`Page errors: ${JSON.stringify(errors, null, 2)}`) + console.error(`Failed requests: ${JSON.stringify(failed, null, 2)}`) + console.error(`Network log errors: ${JSON.stringify(networkErrors.map(entry => entry.text), null, 2)}`) + for (const [name, log] of Object.entries(logs)) { + const tail = log.split('\n').slice(-40).join('\n').trim() + if (tail) console.error(`--- ${name} output (tail) ---\n${tail}`) + } + if (client) { + const pageState = await client.evaluate(`({ + url: location.href, title: document.title, + editors: document.querySelectorAll('.cm-content').length, + dialogs: [...document.querySelectorAll('[role="dialog"]')].map(node => node.textContent.slice(0, 200)), + text: document.body.innerText.slice(0, 1500) + })`).catch((reason) => ({ unavailable: reason.message })) + console.error(`Page state: ${JSON.stringify(pageState, null, 2)}`) + await writeFile(join(run, 'failure.txt'), await client.evaluate('document.body.innerText').catch(() => '')) + const screenshot = await client.send('Page.captureScreenshot', { format: 'png' }).catch(() => null) + if (screenshot) await writeFile(join(run, 'failure.png'), Buffer.from(screenshot.data, 'base64')) + const geometry = await client.evaluate(`({ + url: location.href, + active: document.activeElement?.outerHTML.slice(0, 800), + firstEditorTyping: window.firstEditorTyping, + editors: [...document.querySelectorAll('.cm-content')].map(element => ({attributes:element.outerHTML.slice(0,400), bounds:element.getBoundingClientRect().toJSON()})), + scrollers: [...document.querySelectorAll('.cm-scroller')].map(element => ({bounds:element.getBoundingClientRect().toJSON(),scrollTop:element.scrollTop})) + })`).catch(() => null) + await writeFile(join(run, 'geometry.json'), JSON.stringify(geometry, null, 2)) + } + await writeFile(join(run, 'errors.json'), JSON.stringify({ errors, requests, failed, networkErrors, absenceProbes:[...absenceProbes.values()] }, null, 2)) + throw error +} finally { + client?.close() + for (const child of children.reverse()) child.kill() + for (const [name, log] of Object.entries(logs)) await writeFile(join(run, `${name}.log`), log) +} diff --git a/tooling/scripts/test-app-core-package.mjs b/tooling/scripts/test-app-core-package.mjs new file mode 100644 index 00000000..f89d410d --- /dev/null +++ b/tooling/scripts/test-app-core-package.mjs @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { cp, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { packAppCore } from './pack-app-core.mjs' +import { filesIn, runNpm } from './pack-shared-package.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const candidate = process.argv[2] + ? JSON.parse(await readFile(resolve(process.argv[2]), 'utf8')) + : await packAppCore() +// Keep the consumer for browser checks and diagnosis. It deliberately lives +// outside the checkout, and nested installation exposes undeclared dependencies. +const consumer = await mkdtemp(join(tmpdir(), 'zennotes core consumer & ')) +console.log(`Consumer: ${consumer}`) +for (const entry of [candidate, ...candidate.dependencies]) { + assert.equal(createHash('sha256').update(await readFile(entry.archive)).digest('hex'), entry.sha256, `Candidate checksum mismatch: ${entry.name}`) +} +const lock = JSON.parse(await readFile(join(root, 'package-lock.json'), 'utf8')) +const installedVersion = (name) => { + const version = lock.packages[`node_modules/${name}`]?.version + assert.ok(version, `Missing locked consumer dependency: ${name}`) + return version +} +const runtime = ['react', 'react-dom', 'zustand', '@codemirror/state', '@codemirror/view', '@codemirror/language', '@lezer/common', '@lezer/highlight'] +const development = ['typescript', 'vite', '@types/node', '@types/react', '@types/react-dom'] +await writeFile(join(consumer, 'package.json'), JSON.stringify({ + name: 'zennotes-isolated-core-consumer', private: true, type: 'module', version: '0.0.0', + description: 'Isolated package validation host', homepage: 'https://zennotes.org', + dependencies: Object.fromEntries([ + ...runtime.map((name) => [name, installedVersion(name)]), + ...[candidate, ...candidate.dependencies].map((entry) => [entry.name, `file:${entry.archive}`]) + ]), + devDependencies: Object.fromEntries(development.map((name) => [name, + name === 'vite' && process.env.ZEN_CORE_VITE_VERSION ? process.env.ZEN_CORE_VITE_VERSION : installedVersion(name) + ])) +}, null, 2) + '\n') +runNpm(['install', '--install-strategy=nested', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: consumer, stdio: 'inherit' }) +const require = createRequire(join(consumer, 'package.json')) +const core = createRequire(join(consumer, 'node_modules/@zennotes/app-core/dist/main.js')) +const installed = JSON.parse(await readFile(join(consumer, 'package-lock.json'), 'utf8')).packages +for (const name of runtime) { + assert.equal(core.resolve(name), require.resolve(name), `app-core has a second ${name} instance`) +} +// Drawing libraries own independent Zustand stores. Parser node properties, +// editor extensions, and React hooks must share runtime identity across packages. +for (const name of runtime.filter(name => name !== 'zustand')) { + const copies = Object.keys(installed).filter(path => path === `node_modules/${name}` || path.endsWith(`/node_modules/${name}`)) + assert.equal(copies.length, 1, `Installed multiple ${name} copies: ${copies.join(', ')}`) + for (const [path, entry] of Object.entries(installed)) { + if (!path || !(name in { ...entry.dependencies, ...entry.peerDependencies })) continue + const fromDependency = createRequire(join(consumer, path, 'package.json')) + assert.equal(fromDependency.resolve(name), require.resolve(name), `${path} has a second ${name} instance`) + } +} +for (const privatePath of ['store', 'dist/store.js', 'src/store.ts', 'lib/cm-format']) { + assert.throws(() => require.resolve(`@zennotes/app-core/${privatePath}`), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }) +} +await mkdir(join(consumer, 'src/bridge'), { recursive: true }) +// Exercise the current host adapter, with only public package imports. No source +// aliases or links back to the workspace are available to this build. +const bridge = (await readFile(join(root, 'apps/web/src/bridge/http-bridge.ts'), 'utf8')) + .replaceAll("from '@shared/", "from '@zennotes/shared-domain/") + .replace('supportsHarper: true', "supportsHarper: import.meta.env.VITE_ZEN_CORE_HARPER !== '0'") +await writeFile(join(consumer, 'src/bridge/http-bridge.ts'), bridge) +await cp(join(root, 'apps/web/src/env.d.ts'), join(consumer, 'src/env.d.ts')) +await writeFile(join(consumer, 'src/editor-types.ts'), ` +import { runEditorCommand, hasEditorSelection, type EditorCommand, type EditorInsertionTarget, type EditorViewport } from '@zennotes/app-core/editor' +import { getShellSnapshot, type ShellSnapshot } from '@zennotes/app-core/shell' +import { getBrowseSnapshot } from '@zennotes/app-core/browse' +import { requestNoteBatch, requestEmptyTrash, type NoteBatchResult, requestMoveNote, requestRenameNote, requestArchiveNote, requestTrashNote, restoreNote, requestDeleteNotePermanently, type NoteActionHost, type NoteActionResult } from '@zennotes/app-core/notes' +import { getTasksSnapshot, moveTaskToColumn, getTodayTasks } from '@zennotes/app-core/tasks' +import { getWorkspaceSnapshot, flushWorkspace } from '@zennotes/app-core/workspace' +import { getSettingsSnapshot, setEditorFontSize } from '@zennotes/app-core/settings' +import { getHostInfo } from '@zennotes/app-core/host' +import { getAppCommands, runAppCommand } from '@zennotes/app-core/commands' +import { prompt, confirm } from '@zennotes/app-core/dialogs' +const noteHost: NoteActionHost = { isCurrent: () => true } +const moveResult: Promise = requestMoveNote(noteHost, 'inbox/Note.md') +const renameResult: Promise = requestRenameNote(noteHost, 'inbox/Note.md') +const archiveResult: Promise = requestArchiveNote(noteHost, 'inbox/Note.md') +const trashResult: Promise = requestTrashNote(noteHost, 'inbox/Note.md') +const restoreResult: Promise = restoreNote(noteHost, 'trash/Note.md') +const deleteResult: Promise = requestDeleteNotePermanently(noteHost, 'trash/Note.md') +const batch: Promise = requestNoteBatch(noteHost, ['inbox/Note.md'], 'trash') +const empty: Promise = requestEmptyTrash(noteHost) +const taskGroup = getTasksSnapshot().groupBy +const workspace = getWorkspaceSnapshot() +const settings = getSettingsSnapshot() +const hostInfo = getHostInfo() +const descriptions = getAppCommands() +// @ts-expect-error Task snapshots cannot mutate store state. +getTasksSnapshot().tasks.push({}) +// @ts-expect-error Workspace snapshots contain no operations. +workspace.setState({}) +const command: EditorCommand = 'toggle-bold' +const handled: boolean = runEditorCommand(command) +const selected: boolean = hasEditorSelection() +// @ts-expect-error Only named semantic commands cross the public boundary. +runEditorCommand('dispatch') +// @ts-expect-error Hosts cannot inject arbitrary editor commands. +runEditorCommand(() => true) +// @ts-expect-error Hosts must capture a real target rather than manufacture one. +const forged: EditorInsertionTarget = {} +// @ts-expect-error The public target must not expose a CodeMirror view. +type PrivateView = EditorInsertionTarget['view'] +// @ts-expect-error Host geometry never exposes an editor DOM node. +type PrivateElement = EditorViewport['editor']['dom'] +function checkBounds(viewport: EditorViewport) { + // @ts-expect-error Measured geometry is an immutable snapshot. + viewport.editor.bottom = 100 +} +const snapshot: ShellSnapshot = getShellSnapshot() +// @ts-expect-error Hosts cannot mutate the published note index. +snapshot.notes.push({}) +// @ts-expect-error Metadata is immutable, including each note. +snapshot.notes[0].title = 'Changed' +// @ts-expect-error Body contents are not part of the shell boundary. +type PrivateBody = ShellSnapshot['notes'][number]['body'] +// @ts-expect-error Store operations do not leak through the snapshot. +snapshot.setState({}) +const browse = getBrowseSnapshot() +// @ts-expect-error Folder rows are immutable copies. +browse.folders[0].directory = 'Changed' +// @ts-expect-error Enabled date settings are read-only. +browse.dateDirectories.daily = 'Changed' +// @ts-expect-error The full settings object remains private. +browse.vaultSettings +`) +await writeFile(join(consumer, 'src/main.tsx'), ` +import { installBridge } from './bridge/http-bridge' +installBridge() +// Match native preference/bootstrap ordering before evaluating app-core. +const { renderZenNotesApp } = await import('@zennotes/app-core/main') +const navigation = await import('@zennotes/app-core/navigation') +navigation.installHomeGuard() +const editor = await import('@zennotes/app-core/editor') +const shell = await import('@zennotes/app-core/shell') +const browse = await import('@zennotes/app-core/browse') +const notes = await import('@zennotes/app-core/notes') +if (new URLSearchParams(location.search).has('host')) { + const toolbar = document.createElement('div') + toolbar.id = 'host-keyboard-toolbar' + toolbar.textContent = 'Host keyboard toolbar' + toolbar.style.cssText = 'position:fixed;bottom:0;left:0;right:0;height:80px;background:#524333;color:white;z-index:10000;pointer-events:none' + document.body.append(toolbar) + const overlap = (bounds: {top:number;bottom:number;left:number;right:number}, overlay: HTMLElement | null) => { + if (!overlay) return 0 + const bar = overlay.getBoundingClientRect() + if (bar.top >= bounds.bottom || bar.bottom <= bounds.top || bar.left >= bounds.right || bar.right <= bounds.left) return 0 + return Math.ceil(Math.min(bounds.bottom - bounds.top, bounds.bottom - bar.top + 8)) + } + const registration = editor.installEditorHost({ + nativeTyping: true, + measureBottomInsets: viewport => ({ + layout: overlap(viewport.editor, document.getElementById('host-selection-toolbar')), + scroll: overlap(viewport.scroll, document.getElementById('host-keyboard-toolbar')) + }) + }) + const focus = (event: FocusEvent) => { + if (!(event.target instanceof HTMLElement) || !event.target.classList.contains('cm-content')) return + Object.assign(window, { firstEditorTyping: ['autocorrect','autocapitalize','spellcheck','writingsuggestions'].map(name => (event.target as HTMLElement).getAttribute(name)) }) + document.removeEventListener('focus', focus, true) + } + document.addEventListener('focus', focus, true) + Object.assign(window, { packageHost: registration }) +} +;(window as unknown as { EXCALIDRAW_ASSET_PATH: string }).EXCALIDRAW_ASSET_PATH = '/excalidraw-assets/' +const root = document.getElementById('root')! +renderZenNotesApp(root) +// A host-rendered observer exercises the React hook from outside the package. +const { createRoot } = await import('react-dom/client') +const { useState } = await import('react') +function Browse() { + const [directory, setDirectory] = useState('') + const lifecycle = shell.useShellSnapshot() + const snapshot = browse.useBrowseSnapshot() + const rows = browse.getBrowseDirectory(snapshot, directory) + // This isolated fixture has one fixed vault; native hosts capture their actual vault token. + const host = { isCurrent: () => true } + const run = async (action: () => Promise) => { + Object.assign(window, { lastBrowseAction: null }) + const result = await action() + Object.assign(window, { lastBrowseAction: result }) + } + return
+ {directory || 'All notes'} + + + + + + {rows.folders.map(row =>
+ + + +
)} + {rows.databases.map(row =>
+ + +
)} + {rows.notes.map(row =>
+ + + + + +
)} + {lifecycle.notes.filter(note => note.folder === 'archive' || note.folder === 'trash').map(note =>
+ {note.title} + + {note.folder === 'trash' && } +
)} +
+} +function Selection() { + const path = navigation.useSelectedNotePath() + const snapshot = shell.useShellSnapshot() + return <> + {path ?? 'Home'} + {snapshot.selectedNote?.title ?? 'No note'} + {new URLSearchParams(location.search).has('browse') && } + {new URLSearchParams(location.search).has('shell') &&
+ {(['previous', 'next'] as const).map(direction => )} +
} + {new URLSearchParams(location.search).has('commands') &&
+ {(['toggle-bold', 'undo', 'redo', 'open-search', 'set-task-list'] as const).map(command => + + )} +
} + +} +createRoot(document.getElementById('selection')!).render() +Object.assign(window, { packageNavigation: navigation, packageEditor: editor, packageShell: shell, packageBrowse: browse }) +`) +await writeFile(join(consumer, 'index.html'), 'ZenNotes package consumer
') +await writeFile(join(consumer, 'vite.config.ts'), ` +import { defineConfig } from 'vite' +import { zenNotesAssets } from '@zennotes/app-core/vite' +export default defineConfig({ + base: './', + plugins: zenNotesAssets({ harper: process.env.ZEN_CORE_HARPER !== '0' }), + build: { target: 'es2022', manifest: true, outDir: process.env.ZEN_CORE_OUT_DIR || 'dist' }, + preview: { host: '127.0.0.1', proxy: { + '/api': { target: process.env.ZEN_CORE_SERVER, ws: true }, + '/assets-data': { target: process.env.ZEN_CORE_SERVER } + } } +}) +`) +await writeFile(join(consumer, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', jsx: 'react-jsx', + strict: true, noEmit: true, resolveJsonModule: true, esModuleInterop: true, + lib: ['ES2022', 'DOM', 'DOM.Iterable'], types: ['vite/client', 'node'] + }, include: ['src', 'vite.config.ts'] +}, null, 2)) +execFileSync(process.execPath, [require.resolve('typescript/bin/tsc'), '--noEmit'], { cwd: consumer, stdio: 'inherit' }) +execFileSync(process.execPath, [join(dirname(require.resolve('vite/package.json')), 'bin/vite.js'), 'build'], { cwd: consumer, stdio: 'inherit' }) +const output = await filesIn(join(consumer, 'dist')) +assert.ok(output.some((file) => /harper.*\.wasm$/.test(file)), 'Harper WASM is missing') +assert.ok(output.some((file) => /typst.*\.wasm$/.test(file)), 'Typst WASM is missing') +assert.ok(output.some((file) => /excalidraw-assets\/fonts\/.+\.woff2$/.test(file)), 'Drawing fonts are missing') +assert.ok(output.some((file) => /KaTeX.+\.woff2$/.test(file)), 'Math fonts are missing') +execFileSync(process.execPath, [join(dirname(require.resolve('vite/package.json')), 'bin/vite.js'), 'build'], { + cwd: consumer, stdio: 'inherit', + env: { ...process.env, ZEN_CORE_HARPER: '0', VITE_ZEN_CORE_HARPER: '0', ZEN_CORE_OUT_DIR: 'dist-native-spelling' } +}) +assert.ok(!(await filesIn(join(consumer, 'dist-native-spelling'))).some((file) => /harper.*\.wasm$/.test(file)), 'Native-spelling build included Harper WASM') +const result = { consumer, candidate, passed: ['candidate checksums', 'nested install', 'singleton peers', 'private exports rejected', 'public typecheck', 'production build', 'font and WASM assets', 'native-spelling build omits Harper'] } +await writeFile(join(consumer, 'result.json'), JSON.stringify(result, null, 2) + '\n') +await writeFile(join(root, 'dist/shared-packages/app-core-consumer.json'), JSON.stringify(result, null, 2) + '\n') +console.log(`PASS: ${result.passed.join(', ')}\nEvidence: ${join(consumer, 'result.json')}`) diff --git a/tooling/scripts/test-shared-packages.mjs b/tooling/scripts/test-shared-packages.mjs new file mode 100644 index 00000000..d85e39d0 --- /dev/null +++ b/tooling/scripts/test-shared-packages.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { runNpm } from './pack-shared-package.mjs' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const require = createRequire(join(repoRoot, 'packages/bridge-contract/package.json')) +const consumer = await mkdtemp(join(tmpdir(), 'zennotes contract & domain consumer ')) + +try { + const packed = JSON.parse(execFileSync(process.execPath, [ + join(repoRoot, 'tooling/scripts/pack-shared-package.mjs'), 'bridge-contract' + ], { cwd: repoRoot, encoding: 'utf8' })) + const domain = JSON.parse(execFileSync(process.execPath, [ + join(repoRoot, 'tooling/scripts/pack-shared-package.mjs'), 'shared-domain' + ], { cwd: repoRoot, encoding: 'utf8' })) + await writeFile(join(consumer, 'package.json'), JSON.stringify({ + name: 'contract-consumer', private: true, type: 'module' + })) + // npm ci caches tarballs but never the registry metadata that resolving a + // dependency range needs, so a fully offline install fails on a fresh CI cache. + runNpm([ + 'install', packed.archive, domain.archive, '--ignore-scripts', '--prefer-offline', '--no-audit', '--no-fund' + ], { cwd: consumer, stdio: 'inherit' }) + const installedRoot = join(consumer, 'node_modules/@zennotes/bridge-contract') + const installed = JSON.parse(await readFile(join(installedRoot, 'package.json'), 'utf8')) + assert.equal(installed.version, packed.version) + assert.equal(installed.private, undefined) + assert.deepEqual(installed.dependencies ?? {}, {}) + const bridgeImports = Object.keys(installed.exports).map((path) => `${installed.name}/${path.slice(2)}`) + + const domainRoot = join(consumer, 'node_modules/@zennotes/shared-domain/dist') + const modules = (await readdir(domainRoot, { recursive: true })).filter((path) => path.endsWith('.js')) + const imports = [...bridgeImports, ...modules.map((path) => `@zennotes/shared-domain/${path.replace(/\\/g, '/').slice(0, -3)}`)] + await writeFile(join(consumer, 'runtime.mjs'), ` +import assert from 'node:assert/strict' +import { PORTABLE_PREF_KEYS } from '@zennotes/bridge-contract/app-config' +import { installZenBridge } from '@zennotes/bridge-contract/bridge' +import { IPC } from '@zennotes/bridge-contract/ipc' +assert.ok(PORTABLE_PREF_KEYS.includes('vimMode')) +assert.equal(typeof installZenBridge, 'function') +assert.ok(Object.keys(IPC).length > 0) +for (const name of ${JSON.stringify(imports)}) await import(name) +`) + execFileSync(process.execPath, ['runtime.mjs'], { cwd: consumer, stdio: 'inherit' }) + await writeFile(join(consumer, 'consumer.ts'), ` +import type { ZenBridge } from '@zennotes/bridge-contract/bridge' +import type { VaultTask } from '@zennotes/bridge-contract/tasks' +import type { AppConfigPortable } from '@zennotes/bridge-contract/app-config' +export const platform: Awaited> = 'darwin' +export const priority: VaultTask['priority'] = 'high' +export const prefs: AppConfigPortable = { vimMode: true } +${imports.map((name, index) => `import * as module${index} from '${name}'`).join('\n')} +`) + await writeFile(join(consumer, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', + strict: true, types: [], lib: ['ES2022', 'DOM'], noEmit: true + }, + include: ['consumer.ts'] + })) + execFileSync(process.execPath, [require.resolve('typescript/bin/tsc'), '-p', 'tsconfig.json'], { + cwd: consumer, stdio: 'inherit' + }) + process.stdout.write('Contract and domain packages install, import, and typecheck without workspace source.\n') +} finally { + await rm(consumer, { recursive: true, force: true }) +} diff --git a/tooling/scripts/verify-terminal-compat.mjs b/tooling/scripts/verify-terminal-compat.mjs new file mode 100644 index 00000000..b9102aa9 --- /dev/null +++ b/tooling/scripts/verify-terminal-compat.mjs @@ -0,0 +1,219 @@ +// Run both implementations against disposable, identical vaults. No installed +// command or personal configuration is used. Keep this oracle until Node retires. +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdtemp, + mkdir, + writeFile, + readFile, + readdir, + rm, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const binary = process.argv[2] +if (!binary) + throw new Error( + 'Usage: node tooling/scripts/verify-terminal-compat.mjs /absolute/path/to/zn', + ) +const root = await mkdtemp(join(tmpdir(), 'zn-terminal-compat-')) +const vault = join(root, 'vault'), + config = join(root, 'config') +const body = + '# Project\n\ncafé 日本語. \n#demo\n\n- [ ] Ship migration\n- [x] Existing task\n' +const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith('ZENNOTES_')), +) +Object.assign(env, { + ZENNOTES_CONFIG_DIR: config, + ZENNOTES_USER_DATA_PATH: config, + ZENNOTES_WORKSPACE_SOURCE: 'app', + NO_COLOR: '1', +}) +const report = [] +function normalize(value) { + if (Array.isArray(value)) return value.map(normalize) + if (value && typeof value === 'object') + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [ + k, + /^(createdAt|updatedAt|lastOpenedAt|modifiedAt)$/.test(k) && + typeof v === 'number' + ? '' + : normalize(v), + ]), + ) + if (typeof value === 'string') + return value.replace( + /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/gi, + '', + ) + return value +} +async function snapshot(dir, relative = '') { + const result = {} + for (const entry of await readdir(join(dir, relative), { + withFileTypes: true, + })) { + const key = relative ? `${relative}/${entry.name}` : entry.name + if (entry.isDirectory()) Object.assign(result, await snapshot(dir, key)) + else { + let content = await readFile(join(dir, key), 'utf8') + if (entry.name.endsWith('.json')) { + try { + content = JSON.parse(content) + } catch {} + } + result[key] = normalize(content) + } + } + return result +} +for (const layout of ['inbox', 'root', 'remapped']) { + const note = layout === 'root' ? 'Project.md' : 'inbox/Project.md' + const histories = [] + for (const [engine, command] of [ + ['node', [process.execPath, join(repo, 'apps/desktop/out/main/cli.js')]], + ['go', [resolve(binary)]], + ]) { + await rm(vault, { recursive: true, force: true }) + await rm(config, { recursive: true, force: true }) + for (const dir of ['inbox', 'quick', 'archive', 'trash', '.zennotes']) + await mkdir(join(vault, dir), { recursive: true }) + await mkdir(config) + await writeFile( + join(vault, '.zennotes/vault.json'), + JSON.stringify({ + primaryNotesLocation: layout === 'root' ? 'root' : 'inbox', + ...(layout === 'remapped' + ? { + systemFolderPaths: { + quick: 'Scratch', + trash: 'Deleted', + archive: 'History', + }, + } + : {}), + }), + ) + await writeFile(join(vault, note), body) + await writeFile( + join(config, 'zennotes.config.json'), + JSON.stringify({ + workspaceMode: 'local', + vaultRoot: vault, + localVaults: [{ name: 'Desktop', root: vault }], + }), + ) + const history = [] + function run(args, input) { + const p = spawnSync(command[0], [...command.slice(1), ...args], { + env, + cwd: root, + encoding: 'utf8', + input, + timeout: 15000, + }) + if (p.error) throw p.error + let stdout = p.stdout + try { + stdout = JSON.parse(stdout) + } catch {} + assert.equal(p.status, 0, `${engine}: ${args.join(' ')}: ${p.stderr}`) + history.push({ + args, + status: p.status, + stdout: normalize(stdout), + stderr: p.stderr, + }) + return stdout + } + run(['list', '--json']) + run(['read', note, '--json']) + run(['search', 'café', '--json']) + run(['vault', 'info', '--json']) + run(['folder', 'list', '--json']) + run(['tag', 'list', '--json']) + const tasks = run(['task', 'list', '--all', '--json']) + run(['task', 'toggle', tasks[0].id, '--json']) + run(['append', note, '--body', '\nAppended ✓ ', '--json']) + run(['write', note, '--body', '-', '--json'], body) + assert.equal( + await readFile(join(vault, note), 'utf8'), + body, + 'stdin bytes must survive exactly', + ) + run(['folder', 'create', 'inbox/Work', '--json']) + run(['folder', 'rename', 'inbox/Work', '--to', 'inbox/Renamed', '--json']) + const captured = run([ + 'capture', + 'Pipe café 日本語', + '--title', + 'Captured', + '--folder', + 'quick', + '--json', + ]) + run(['trash', captured.path, '--json']) + run([ + 'restore', + `${layout === 'remapped' ? 'Deleted' : 'trash'}/Captured.md`, + '--json', + ]) + run(['comment', 'add', note, 'Review this', '--author', 'Test', '--json']) + run(['comment', 'list', note, '--json']) + run(['base', 'create', 'Migration', '--json']) + run(['base', 'add', 'Migration', '--set', 'Name=Fixture', '--json']) + run(['base', 'rows', 'Migration', '--json']) + histories.push({ engine, history, files: await snapshot(vault) }) + } + const [node, go] = histories + const comparisons = node.history.map((check, i) => { + let equal = true + try { + assert.deepEqual(go.history[i], check) + } catch { + equal = false + } + return { + args: check.args, + equal, + ...(equal ? {} : { node: check, go: go.history[i] }), + } + }) + let filesEqual = true + try { + assert.deepEqual(go.files, node.files) + } catch { + filesEqual = false + } + report.push({ + layout, + comparisons, + filesEqual, + ...(filesEqual ? {} : { nodeFiles: node.files, goFiles: go.files }), + }) +} +const reportPath = join(root, 'report.json') +await writeFile(reportPath, JSON.stringify(report, null, 2) + '\n') +console.log( + JSON.stringify( + { + reportPath, + results: report.map((r) => ({ + layout: r.layout, + commands: r.comparisons.length, + matching: r.comparisons.filter((c) => c.equal).length, + filesEqual: r.filesEqual, + })), + }, + null, + 2, + ), +) +if (report.some((r) => !r.filesEqual || r.comparisons.some((c) => !c.equal))) + process.exitCode = 1 diff --git a/tooling/scripts/web-dist-lock.mjs b/tooling/scripts/web-dist-lock.mjs index bd795991..19e5ddf2 100644 --- a/tooling/scripts/web-dist-lock.mjs +++ b/tooling/scripts/web-dist-lock.mjs @@ -9,14 +9,15 @@ const repoRoot = resolve(scriptDir, '..', '..') // One lock serializes every process that produces the web bundle: the vite // build that fills apps/web/dist (it empties the directory first, so a reader -// can otherwise stage a half-written tree) and the swap that moves that tree -// into apps/server/web/dist (which briefly has no dist/ at all, and `go:embed -// all:dist` cannot compile in that window). It lives next to the tree it -// guards so a leftover lock is easy to spot and delete by hand. -export const WEB_DIST_LOCK_DIR = resolve(repoRoot, 'apps/server/web/.web-dist.lock') +// can otherwise stage a half-written tree), the artifact packer that reads it, +// and the sync that moves that tree into an external server checkout's +// web/dist (which briefly has no dist/ at all, and `go:embed all:dist` cannot +// compile in that window). It lives next to the tree it guards so a leftover +// lock is easy to spot and delete by hand. +export const WEB_DIST_LOCK_DIR = resolve(repoRoot, 'apps/web/.web-dist.lock') const OWNER_FILE = resolve(WEB_DIST_LOCK_DIR, 'owner.json') -// A holder still running after this long is presumed wedged; a vite build plus -// a directory copy is a matter of seconds. +// Only an ownerless lock can expire by age. A cold Go build may legitimately +// hold the lock much longer while the compiler reads embedded browser assets. const STALE_MS = 10 * 60 * 1000 const POLL_MS = 50 // Handed to child processes so a locked script that shells out to another @@ -64,9 +65,7 @@ async function lockIsStale() { return false } } - // A recycled pid can make a dead holder look alive, so age is also checked. - if (!pidIsAlive(owner.pid)) return true - return Date.now() - (owner.startedAt ?? 0) > STALE_MS + return !pidIsAlive(owner.pid) } async function releaseLock(token) { diff --git a/tooling/scripts/web-dist-lock.test.mjs b/tooling/scripts/web-dist-lock.test.mjs new file mode 100644 index 00000000..437d5926 --- /dev/null +++ b/tooling/scripts/web-dist-lock.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { setTimeout } from 'node:timers/promises' +import test from 'node:test' + +test('a second producer waits for a live owner even after ten minutes', async () => { + const root = await mkdtemp(join(tmpdir(), 'zennotes-lock-test-')) + const script = join(root, 'tooling/scripts/web-dist-lock.mjs') + let pending + try { + await mkdir(dirname(script), { recursive: true }) + await copyFile(new URL('./web-dist-lock.mjs', import.meta.url), script) + const { WEB_DIST_LOCK_DIR, withWebDistLock } = await import(pathToFileURL(script).href) + await mkdir(WEB_DIST_LOCK_DIR, { recursive: true }) + await writeFile(join(WEB_DIST_LOCK_DIR, 'owner.json'), JSON.stringify({ + token: 'slow-compiler', pid: process.pid, startedAt: Date.now() - 60 * 60 * 1000 + })) + let acquired = false + pending = withWebDistLock(async () => { acquired = true }) + await setTimeout(150) + try { + assert.equal(acquired, false, 'a live compiler lost its lock based on age') + } finally { + await rm(WEB_DIST_LOCK_DIR, { recursive: true, force: true }) + await pending + } + assert.equal(acquired, true, 'the waiting producer did not acquire the released lock') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/tooling/server-release.json b/tooling/server-release.json new file mode 100644 index 00000000..291665b4 --- /dev/null +++ b/tooling/server-release.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "repository": "ZenNotes/znserver", + "version": "v2.50.4", + "assets": { + "darwin-arm64": { "file": "zennotes-server-darwin-arm64", "sha256": "13295e97392fadc95cc6c7b35386d993c7dcc159e8e3b0bbe7b0f282bef02409" }, + "darwin-x64": { "file": "zennotes-server-darwin-amd64", "sha256": "1ccaff087bfc33b7741cc4e014e638a60cc392bf8563931ce2b3d498a6cb3aca" }, + "linux-arm64": { "file": "zennotes-server-linux-arm64", "sha256": "72fdc108db6eda4e4a1476835177955960f2b3f6b095625e5082d146c8321168" }, + "linux-x64": { "file": "zennotes-server-linux-amd64", "sha256": "15a137b0e1cabd77c0de7f7e0b359696acdd94955f4dab5fbf65b1dc26cd8d7c" }, + "win32-x64": { "file": "zennotes-server-windows-amd64.exe", "sha256": "f90dc3444de8b84bb76fac991ae298881cd31e8c5c75f4b45db5caf520e84e7d" } + } +}